From fcc2b5e2827e8579e01402988e8576946d3b4362 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 31 Jul 2026 19:21:16 +0800 Subject: [PATCH] refactor agent pre-step inbox lifecycle --- ...-29-addressable-queue-operations.i18n.yaml | 0 ...2026-07-29-addressable-queue-operations.md | 0 ...6-07-29-addressable-queue-operations.zh.md | 0 ...026-06-11-event-sourced-sessions.i18n.yaml | 4 +- .../2026-06-11-event-sourced-sessions.md | 2 +- .../2026-06-11-event-sourced-sessions.zh.md | 2 +- ...06-11-microkernel-event-taxonomy.i18n.yaml | 4 +- .../2026-06-11-microkernel-event-taxonomy.md | 6 +- ...026-06-11-microkernel-event-taxonomy.zh.md | 6 +- ...026-06-30-event-domain-semantics.i18n.yaml | 4 +- .../2026-06-30-event-domain-semantics.md | 2 +- .../2026-06-30-event-domain-semantics.zh.md | 2 +- ...6-07-05-reconstructable-requests.i18n.yaml | 4 +- .../2026-07-05-reconstructable-requests.md | 10 +- .../2026-07-05-reconstructable-requests.zh.md | 10 +- ...n-pressure-and-overflow-recovery.i18n.yaml | 4 +- ...mpaction-pressure-and-overflow-recovery.md | 14 +- ...ction-pressure-and-overflow-recovery.zh.md | 14 +- ...07-12-agent-scope-runtime-design.i18n.yaml | 6 +- .../2026-07-12-agent-scope-runtime-design.md | 8 +- ...026-07-12-agent-scope-runtime-design.zh.md | 8 +- ...-14-provider-routed-llm-adapters.i18n.yaml | 4 +- ...2026-07-14-provider-routed-llm-adapters.md | 2 +- ...6-07-14-provider-routed-llm-adapters.zh.md | 2 +- ...07-15-replay-token-meter-service.i18n.yaml | 4 +- .../2026-07-15-replay-token-meter-service.md | 2 +- ...026-07-15-replay-token-meter-service.zh.md | 2 +- ...07-16-explicit-turn-cancellation.i18n.yaml | 4 +- .../2026-07-16-explicit-turn-cancellation.md | 6 +- ...026-07-16-explicit-turn-cancellation.zh.md | 6 +- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 20 +- ...ied-send-and-coalesced-user-messages.zh.md | 20 +- ...xt-injection-from-turn-execution.i18n.yaml | 4 +- ...e-context-injection-from-turn-execution.md | 36 ++-- ...ontext-injection-from-turn-execution.zh.md | 36 ++-- ...ession-scope-and-provide-channel.i18n.yaml | 4 +- ...lient-session-scope-and-provide-channel.md | 3 +- ...nt-session-scope-and-provide-channel.zh.md | 3 +- ...ntified-immutable-message-values.i18n.yaml | 4 +- ...-28-identified-immutable-message-values.md | 6 +- ...-identified-immutable-message-values.zh.md | 6 +- ...claimed-pre-step-inbox-lifecycle.i18n.yaml | 6 + ...-07-31-claimed-pre-step-inbox-lifecycle.md | 41 ++++ ...-31-claimed-pre-step-inbox-lifecycle.zh.md | 41 ++++ ...-21-semantic-session-checkpoints.i18n.yaml | 4 +- ...2026-07-21-semantic-session-checkpoints.md | 4 +- ...6-07-21-semantic-session-checkpoints.zh.md | 4 +- ...06-18-compaction-capability-seam.i18n.yaml | 4 +- .../2026-06-18-compaction-capability-seam.md | 6 +- ...026-06-18-compaction-capability-seam.zh.md | 6 +- .../2026-06-24-workspace-context.i18n.yaml | 4 +- .../feature/2026-06-24-workspace-context.md | 8 +- .../2026-06-24-workspace-context.zh.md | 8 +- .../feature/2026-06-30-hook-bridges.i18n.yaml | 4 +- .../feature/2026-06-30-hook-bridges.md | 8 +- .../feature/2026-06-30-hook-bridges.zh.md | 8 +- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- .../2026-06-30-interception-seams.i18n.yaml | 4 +- .../feature/2026-06-30-interception-seams.md | 6 +- .../2026-06-30-interception-seams.zh.md | 6 +- .../feature/2026-07-05-skill-system.i18n.yaml | 4 +- .../feature/2026-07-05-skill-system.md | 2 +- .../feature/2026-07-05-skill-system.zh.md | 2 +- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +- .../implemented/feature/2026-07-06-sandbox.md | 4 +- .../feature/2026-07-06-sandbox.zh.md | 4 +- ...16-durable-per-step-time-context.i18n.yaml | 4 +- ...026-07-16-durable-per-step-time-context.md | 6 +- ...-07-16-durable-per-step-time-context.zh.md | 6 +- ...9-same-session-goal-round-driver.i18n.yaml | 4 +- ...26-07-19-same-session-goal-round-driver.md | 6 +- ...07-19-same-session-goal-round-driver.zh.md | 6 +- ...6-07-21-cross-session-references.i18n.yaml | 4 +- .../2026-07-21-cross-session-references.md | 8 +- .../2026-07-21-cross-session-references.zh.md | 8 +- ...7-23-web-permission-and-approval.i18n.yaml | 4 +- .../2026-07-23-web-permission-and-approval.md | 2 +- ...26-07-23-web-permission-and-approval.zh.md | 2 +- ...-07-27-skill-catalog-hot-refresh.i18n.yaml | 4 +- .../2026-07-27-skill-catalog-hot-refresh.md | 2 +- ...2026-07-27-skill-catalog-hot-refresh.zh.md | 2 +- ...2026-07-27-tmux-location-context.i18n.yaml | 4 +- .../2026-07-27-tmux-location-context.md | 6 +- .../2026-07-27-tmux-location-context.zh.md | 6 +- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +- .../2026-07-17-one-send-one-turn.md | 16 +- .../2026-07-17-one-send-one-turn.zh.md | 16 +- ...lan-specific-collaboration-state.i18n.yaml | 4 +- ...07-22-plan-specific-collaboration-state.md | 4 +- ...22-plan-specific-collaboration-state.zh.md | 4 +- ...nt-loop-observable-state-machine.i18n.yaml | 4 +- ...-24-agent-loop-observable-state-machine.md | 8 +- ...-agent-loop-observable-state-machine.zh.md | 8 +- ...-remove-synthetic-log-only-turns.i18n.yaml | 4 +- ...6-07-28-remove-synthetic-log-only-turns.md | 4 +- ...7-28-remove-synthetic-log-only-turns.zh.md | 4 +- docs/agent-lifecycle.md | 38 ++-- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 48 ++--- docs/architecture.zh.md | 48 ++--- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 4 +- docs/cookbook/extension-cookbook.zh.md | 4 +- docs/cordis-catalog/events.md | 119 +++++++---- .../core-data-structures/compaction.i18n.yaml | 4 +- docs/core-data-structures/compaction.md | 2 +- docs/core-data-structures/compaction.zh.md | 2 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 47 ++-- docs/core-data-structures/core.zh.md | 47 ++-- docs/core-data-structures/session.i18n.yaml | 4 +- docs/core-data-structures/session.md | 9 +- docs/core-data-structures/session.zh.md | 9 +- docs/core-data-structures/skills.i18n.yaml | 4 +- docs/core-data-structures/skills.md | 2 +- docs/core-data-structures/skills.zh.md | 2 +- docs/event-producer-consumer.md | 22 +- docs/persistence-catalog.md | 33 ++- docs/user/develop/framework/events.i18n.yaml | 4 +- docs/user/develop/framework/events.md | 2 +- docs/user/develop/framework/events.zh.md | 2 +- .../tests/fixtures/goal-domain/seed-goal.ts | 14 +- packages/acp/acp/tests/turns.spec.ts | 38 +--- .../client/connection/src/client/fixture.ts | 4 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../compact/compact-basic/README.i18n.yaml | 4 +- packages/compact/compact-basic/README.md | 2 +- packages/compact/compact-basic/README.zh.md | 2 +- packages/compact/compact-basic/src/index.ts | 34 +-- .../compact-basic/tests/compact-basic.spec.ts | 35 +-- packages/context/README.i18n.yaml | 4 +- packages/context/README.md | 4 +- packages/context/README.zh.md | 4 +- .../session-reference/README.i18n.yaml | 4 +- packages/context/session-reference/README.md | 2 +- .../context/session-reference/README.zh.md | 2 +- .../context/time-context/README.i18n.yaml | 4 +- packages/context/time-context/README.md | 8 +- packages/context/time-context/README.zh.md | 8 +- packages/context/time-context/src/index.ts | 36 ++-- .../context/time-context/src/invariant.ts | 11 +- .../time-context/tests/invariant.spec.ts | 13 +- .../time-context/tests/time-context.spec.ts | 43 ++-- .../context/tmux-context/README.i18n.yaml | 4 +- packages/context/tmux-context/README.md | 4 +- packages/context/tmux-context/README.zh.md | 4 +- packages/context/tmux-context/src/index.ts | 43 ++-- .../tmux-context/tests/tmux-context.spec.ts | 25 +-- .../workspace-context/README.i18n.yaml | 4 +- packages/context/workspace-context/README.md | 6 +- .../context/workspace-context/README.zh.md | 6 +- .../context/workspace-context/src/index.ts | 77 +++++-- .../tests/workspace-context.spec.ts | 109 +++++++++- .../cordis/tool-cordis/src/api-catalog.ts | 52 +++-- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 8 +- packages/core/agent-loop/README.zh.md | 8 +- packages/core/agent-loop/src/agent.ts | 72 +++---- packages/core/agent-loop/src/tool-calls.ts | 2 +- .../agent-loop/tests/agent-initiator.spec.ts | 19 +- packages/core/agent-loop/tests/agent.spec.ts | 32 ++- packages/core/agent-loop/tests/cancel.spec.ts | 14 +- .../tests/contract-regressions.spec.ts | 90 ++++---- .../agent-loop/tests/interception.spec.ts | 200 ++++++++---------- packages/core/agent-loop/tests/loop.spec.ts | 46 ++-- packages/core/agent-loop/tests/resume.spec.ts | 2 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 2 +- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 14 +- packages/core/agent/README.zh.md | 14 +- packages/core/agent/src/inbox.ts | 102 ++++++++- packages/core/agent/src/types.ts | 88 ++++---- packages/core/agent/tests/agent.spec.ts | 2 +- .../core/scope/src/scoped-events.generated.ts | 6 +- packages/core/scope/tests/invariant.spec.ts | 6 +- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 4 +- packages/core/session/README.zh.md | 4 +- packages/core/session/src/types.ts | 11 +- .../examples/acp-demo/tests/acp-agent.spec.ts | 11 +- .../agent-spine-demo/tests/agent-core.spec.ts | 11 +- .../examples/cli-demo/tests/cli-demo.spec.ts | 11 +- packages/examples/cli-demo/tests/cli.spec.ts | 25 +-- .../tests/tools.spec.ts | 2 +- .../command-goal/tests/command-goal.spec.ts | 4 +- packages/goal/goal-session/README.i18n.yaml | 4 +- packages/goal/goal-session/README.md | 4 +- packages/goal/goal-session/README.zh.md | 4 +- packages/goal/goal-session/src/index.ts | 35 ++- .../goal-session/tests/goal-session.spec.ts | 61 +++--- packages/goal/goal/README.i18n.yaml | 4 +- packages/goal/goal/README.md | 2 +- packages/goal/goal/README.zh.md | 2 +- packages/goal/goal/src/index.ts | 2 +- packages/goal/goal/tests/goal.spec.ts | 4 +- packages/goal/goal/tests/projection.spec.ts | 2 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 2 +- packages/guard/README.i18n.yaml | 4 +- packages/guard/README.md | 2 +- packages/guard/README.zh.md | 2 +- .../guard/repeat-tool-guard/README.i18n.yaml | 4 +- packages/guard/repeat-tool-guard/README.md | 2 +- packages/guard/repeat-tool-guard/README.zh.md | 2 +- packages/guard/repeat-tool-guard/src/index.ts | 6 +- packages/hooks/hook-protocol/src/types.ts | 2 +- packages/hooks/hooks-claude/README.i18n.yaml | 4 +- packages/hooks/hooks-claude/README.md | 4 +- packages/hooks/hooks-claude/README.zh.md | 4 +- packages/hooks/hooks-claude/src/index.ts | 28 +-- .../hooks/hooks-claude/tests/bridge.spec.ts | 4 +- .../hooks-claude/tests/coverage-cases.ts | 14 +- packages/hooks/hooks-codex/README.i18n.yaml | 4 +- packages/hooks/hooks-codex/README.md | 4 +- packages/hooks/hooks-codex/README.zh.md | 4 +- packages/hooks/hooks-codex/src/index.ts | 30 +-- .../hooks/hooks-codex/tests/coverage-cases.ts | 14 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 2 +- .../tests/api-proxy-projections.spec.ts | 2 +- .../tests/api-proxy-workspace.spec.ts | 2 +- packages/plan/plan-mode/README.i18n.yaml | 4 +- packages/plan/plan-mode/README.md | 2 +- packages/plan/plan-mode/README.zh.md | 2 +- packages/plan/plan-mode/src/index.ts | 54 +++-- .../plan/plan-mode/tests/integration.spec.ts | 3 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 127 +++++------ packages/pty/pty-local/tests/index.spec.ts | 6 +- packages/pty/pty-local/tests/local.spec.ts | 2 +- packages/pty/pty/tests/service.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 2 +- .../tool-bash-persistent/tests/tools.spec.ts | 2 +- .../tool-pty/tests/loader-composition.spec.ts | 2 +- packages/pty/tool-pty/tests/tools.spec.ts | 2 +- .../README.i18n.yaml | 4 +- .../session-checkpoint-policy/README.md | 4 +- .../session-checkpoint-policy/README.zh.md | 4 +- .../session-checkpoint-policy/src/index.ts | 7 +- .../tests/session-checkpoint-policy.spec.ts | 8 +- packages/skill/tool-skill/README.i18n.yaml | 4 +- packages/skill/tool-skill/README.md | 6 +- packages/skill/tool-skill/README.zh.md | 6 +- packages/skill/tool-skill/src/index.ts | 42 +++- .../skill/tool-skill/tests/tool-skill.spec.ts | 61 ++++-- .../tasks/tasks-local/tests/tasks.spec.ts | 2 +- packages/tasks/tool-tasks/README.i18n.yaml | 4 +- packages/tasks/tool-tasks/README.md | 2 +- packages/tasks/tool-tasks/README.zh.md | 2 +- packages/tasks/tool-tasks/src/index.ts | 8 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/index.ts | 62 +++--- packages/ui/tui/tests/harness.ts | 7 +- .../tui/tests/session-reference.snapshot.ts | 2 +- packages/ui/tui/tests/tui.spec.ts | 137 ++++++------ packages/ui/user-approval/src/index.ts | 50 +++-- .../ui/user-approval/tests/approval.spec.ts | 70 ++++-- scripts/gen-cordis-catalog.ts | 5 +- scripts/gen-doc-graphs.ts | 38 ++-- scripts/type-equiv.manifest.json | 7 +- 267 files changed, 2052 insertions(+), 1546 deletions(-) rename .agents/notes/{implemented => archived}/feature/2026-07-29-addressable-queue-operations.i18n.yaml (100%) rename .agents/notes/{implemented => archived}/feature/2026-07-29-addressable-queue-operations.md (100%) rename .agents/notes/{implemented => archived}/feature/2026-07-29-addressable-queue-operations.zh.md (100%) create mode 100644 .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md create mode 100644 .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml b/.agents/notes/archived/feature/2026-07-29-addressable-queue-operations.i18n.yaml similarity index 100% rename from .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml rename to .agents/notes/archived/feature/2026-07-29-addressable-queue-operations.i18n.yaml diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md b/.agents/notes/archived/feature/2026-07-29-addressable-queue-operations.md similarity index 100% rename from .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md rename to .agents/notes/archived/feature/2026-07-29-addressable-queue-operations.md diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md b/.agents/notes/archived/feature/2026-07-29-addressable-queue-operations.zh.md similarity index 100% rename from .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md rename to .agents/notes/archived/feature/2026-07-29-addressable-queue-operations.zh.md diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml index 6ea6fce11e..e05c87ed11 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-event-sourced-sessions.md: 15ba7b23d5eae48e7dee2328b5924493d54aeeb0 -2026-06-11-event-sourced-sessions.zh.md: da3be5965a6900076f253cad065b847c6f5ce17e +2026-06-11-event-sourced-sessions.md: 01f9628c1cfc000aca8654caf5edeff09411fdcc +2026-06-11-event-sourced-sessions.zh.md: 4975d39399a3805d2bd22cca7d17a4f5108c7915 diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md index 15ba7b23d5..01f9628c1c 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md @@ -14,7 +14,7 @@ A `Session` is an append-only log of typed `SessionEvent`s — the single source Appends are synchronous (the hot path never blocks on I/O); `session/event` is a sync notification; persistence plugins buffer write-behind and drain at the awaited `session/flush` checkpoint fired at every turn end. -Ordering contract: the loop appends to the session *before* emitting the corresponding Cordis event, and the `agent/step-result` waterfall runs before the `assistant/message` append so the log records the message tool dispatch actually used. Regression tests pin that ordering. +Ordering contract: the loop claims inbox messages before `agent/pre-step`, opens `step/start` only after an enter decision, then appends the returned `user/message` batch before request derivation. Provider output is assembled and appended as `assistant/message` before tool dispatch, so the durable log records the exact message the tools follow. Regression tests pin that ordering. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md index da3be5965a..4975d39399 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md @@ -14,7 +14,7 @@ MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严 追加操作是同步的(热路径从不阻塞于 I/O);`session/event` 是同步通知;持久化插件在后台缓冲写入,并在每个轮次结束时触发的 `session/flush` 检查点处等待排空。 -顺序契约:agent loop(智能体循环)*先*追加到会话,再发出对应的 Cordis 事件;`agent/step-result` waterfall(瀑布式事件)在 `assistant/message` 追加之前运行,因此日志记录的是工具调度实际使用的消息。回归测试固定了这一顺序。 +顺序契约:agent loop(智能体循环)先领取 inbox 消息,再运行 `agent/pre-step`;只有 enter 决策才打开 `step/start`,随后在请求派生前追加返回的 `user/message` 批次。提供方输出组装并以 `assistant/message` 追加后才分派工具,因此持久日志记录工具实际遵循的确切消息。回归测试固定了这一顺序。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml index a15ebcfddf..363979f4ec 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-microkernel-event-taxonomy.md: 8bf05b7deba5f054d4ec8ecf104c3b8798e42d4e -2026-06-11-microkernel-event-taxonomy.zh.md: 4ff2ab632ca02e98137a15f19a7996a740a519b0 +2026-06-11-microkernel-event-taxonomy.md: 202595fed125966a5d77920536e7f4ee88f875fe +2026-06-11-microkernel-event-taxonomy.zh.md: 899c96d86cb7e37d90df349ce5f3f932e0a72f95 diff --git a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md index 8bf05b7deb..202595fed1 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md +++ b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md @@ -12,10 +12,10 @@ The product principle is "everything is a plugin": hooks, /goal, /loop, dynamic 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. +- **waterfall** (around-middleware) where plugins transform, veto, recover, or wrap: `agent/pre-step`, `agent/request`, `agent/request-error`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`. +- **serial** (awaited in listener order) for ordered checkpoints such as `agent/turn-stopping`. - **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. +- **emit** (synchronous fire-and-forget) for notifications: inbox transitions, lifecycle, errors, and the contained immutable `tools/result` observation. Durable session events own turn and step boundaries. 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. diff --git a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md index 4ff2ab632c..899c96d86c 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md @@ -12,10 +12,10 @@ Status: implemented 纯 Cordis 事件分类体系。agent loop(智能体循环)的扩展 seam 是带类型的事件,具有明确的分发模式: -- **waterfall(瀑布式事件)**(around-middleware):插件可变换、否决、恢复或包装:`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**(按监听器顺序依次 await;bail 值会阻止后续监听器执行):用于有序检查点。所有 `agent/pre-step` 和 `agent/post-step` 监听器在全部弃权时才继续运行,而 `agent/turn-stop` 返回的第一个 stop 值即为最终的终止决策。 +- **waterfall(瀑布式事件)**(around-middleware):插件可变换、否决、恢复或包装:`agent/pre-step`、`agent/request`、`agent/request-error`、`tools/pre-execute`、`tools/execute`、`tools/post-execute`、`llm/stream`、`system-prompt/assemble`。 +- **serial**(按监听器顺序依次 await):用于 `agent/turn-stopping` 等有序检查点。 - **parallel**(await 扇出):每个监听器都必须获得独立执行的机会:`session/flush` 持久性检查点。 -- **emit**(同步 fire-and-forget):用于通知:轮次/步骤边界、流分片、生命周期、错误,以及包含不可变 `tools/result` 观测的事件。 +- **emit**(同步 fire-and-forget):用于 inbox 转换、生命周期、错误,以及包含不可变 `tools/result` 观测的事件。轮次与步骤边界由持久会话事件拥有。 事件词汇定义在接口包中(dsh-agent 声明 agent/* 事件);`@deepseek-ai/dsh-agent-loop` 是唯一的具体循环插件,且自身可替换——外部不得依赖它。 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml index c6d8176b90..3716909c82 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-event-domain-semantics.md: 75c1cac11d1bfc9aa7fba9c523eab8c0475027e8 -2026-06-30-event-domain-semantics.zh.md: a412b8735b72274252473f3218e0d57d4f814bde +2026-06-30-event-domain-semantics.md: 3127aa5593e199315fa88ba6cf0848636d1f2248 +2026-06-30-event-domain-semantics.zh.md: 161632083d5ce57d903f35a4095a1c6fbee867f5 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md index 75c1cac11d..3127aa5593 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -21,7 +21,7 @@ This vocabulary is the foundation for interception decisions, the durable `hook/ **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 replay projections 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`). +- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Interception waterfalls (`agent/pre-step`, `agent/request`, `agent/request-error`) transform, reject, or recover; awaited `agent/turn-stopping` observes the stop boundary; transient emits report lifecycle, status, inbox insertion/claim/discard, and errors. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`, as 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. diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md index a412b8735b..161632083d 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md @@ -21,7 +21,7 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环) **三个域,各司其职,以一条边界规则统一。** - **`session/*`——持久的、可回放的事实日志。** 拥有 `SessionEventMap`;每条记录仅含 JSON(无活对象)。每次追加触发一次 `session/event` emit,加上 `session/flush` 并行持久性检查点。它同时也是实时 transcript(文本记录)源:想渲染或响应已发生事件的消费方在此订阅,因此实时渲染与回放投影共享同一路径。 -- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。两种形态:拦截 waterfall(瀑布式事件)(`agent/request`、`agent/step-result`、`agent/turn-continuation`)可变更或否决;瞬态 emit(`agent/status`、`agent/error`、`agent/created`/`agent/disposed`、`agent/queued`)在持有 `Agent` 的情况下通知。轮次和步骤边界不在此处——它们是持久的会话事件,从 `session/event` 读取;token 流(`assistant/chunk`)和中途 steering(中途引导)(`steering/message`)同理。 +- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。拦截 waterfall(瀑布式事件)(`agent/pre-step`、`agent/request`、`agent/request-error`)负责变换、拒绝或恢复;awaited `agent/turn-stopping` 观察停止边界;瞬态 emit 报告生命周期、状态、inbox 插入/领取/丢弃与错误。轮次和步骤边界不在此处——它们是持久的会话事件,从 `session/event` 读取;token 流(`assistant/chunk`)和中途 steering(`steering/message`)同理。 - **`tools/*`——工具注册表与执行 seam。** **边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于会话日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index f9c309c39f..f1e7ec6ad9 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-05-reconstructable-requests.md: 153d37a2faf2265134d5ff9e88f0bbfa275328e0 -2026-07-05-reconstructable-requests.zh.md: caf51c3065e416fd11aebc1c1d4dc2ee248e2e5c +2026-07-05-reconstructable-requests.md: 2f559a3052b9fb84f788975a64799e4f020b0d3e +2026-07-05-reconstructable-requests.zh.md: 8635b46979edbe7c0fa11203eacfeceec24adc54 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index 153d37a2fa..2f559a3052 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -22,11 +22,11 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro **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. +`EpochHeader` records the request's non-history state: call config, rendered system prompt, and tool schemas, 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. +Each proposed step first claims its inbox batch and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start` and records the final message batch as `user/message` events. The step then assembles the system prompt and tools, while `agent/request` may replace only the frozen call-config seed. The loop records the owed full header snapshot, builds `GenerateOptions` from derived messages and that header, and deep-freezes it while leaving `AbortSignal` live. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. -**`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. +**The open step is the reconstruction boundary.** Its entered `user/message` batch and any newly written `request/header` precede request dispatch. Injection after the atomic claim joins a later request, while a listener that must affect this request returns messages through `agent/pre-step`. 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. @@ -47,9 +47,9 @@ Like MiniCode, the conversation advances append-only and resets only when model- ## 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). +- Model-visible context uses logged message channels. `agent.inject()` and tool `additionalContexts` enter the inbox for a later claim, while `agent/pre-step` returns context that must settle with the current claimed batch. Each entered value is a durable sourced `user/message`, paid once and prefix-cached thereafter at the price of accumulating in history until compaction. - 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. +- `agent/pre-step` is the current-request message seam; direct inbox mutation is the eventual later-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. diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index caf51c3065..8635b46979 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -22,11 +22,11 @@ Status: implemented **消息。** `Session.deriveMessages()` 带缓存:每个 surface 条目在首次出现时通过公开的逐事件函数 `deriveEventMessage(event)` 精确投影一次;surface 重写(压缩的 `replace`,即 `SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,底层是共享的深度冻结消息:通过投影变异已记录的历史是不可表达的(会抛异常),取代了旧的逐次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。 -`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词、工具 schema 和会话前缀,空值规范化为缺失。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。 +`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词和工具 schema,空值规范化为缺失。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。 -每个步骤重建提示词组装。在实例的首个步骤中,`agent/session-prefix` 以一个冻结的空种子为基础,用仅限请求的开场消息进行扩展;结果在通用 `agent/pre-step` 检查点与边界快照之前被冻结并缓存于该循环实例。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。`agent/request` 只能替换那个冻结的配置种子,模型可见内容通过已记录的通道进入。循环记录欠下的 header 事件(前缀唯一的持久归宿),从前缀、快照和 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。每实例状态仅有缓存的前缀和锚定快照是否已写入。 +每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,并把最终消息批次记录为 `user/message` 事件。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的完整 header 快照,从派生消息与该 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。 -**`step/start` 是重建边界。** 一个步骤从该序列之前的事件推导消息。快照之后的注入加入下一次请求,事件发布期间的重入追加被拒绝。`agent/pre-step(agent, turn, step, signal)` 仍是当前请求所需内容的通用 seam。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。 +**已打开步骤是重建边界。** 进入步骤的 `user/message` 批次与任何新写入的 `request/header` 都位于请求分派之前。原子领取后发生的注入加入后续请求;必须影响本次请求的监听器则通过 `agent/pre-step` 返回消息。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。 **强制执行。** `dsh-agent-loop/invariant` 配套插件向 `ctx.invariants` 注册,并在被选用时通过一个全新的 `Session` 独立重建每个循环请求,使活跃缓存无法为自身背书,然后在 `llm/stream` 处比较消息和折叠后的 header 字段。循环通过 `dsh-llm` 的 `markAgentLoopRequest()` 记录精确的冻结请求;这一进程内标识让配套插件和其他请求观察者识别对话工作,而直接的一次性调用无论其冻结形状或会话 id 如何都保持排除。正确性依赖于序列有界的重建,而非监听器顺序。带密钥的 e2e 要求首次请求之后有正值的 cache-read token;逐步骤用量是生产信号,header 变更或压缩表现为下一步骤的 cache-read 下降。 @@ -47,9 +47,9 @@ Status: implemented ## 后果 - 一个日志无法解释的请求不可能被意外构造——无论是循环还是监听器;变异已构建的请求会抛异常;每个 header 变更都是持久的、可 diff 的日志事件。 -- 在建议性通道之间做选择是变更频率的决策,而本设计使稳定的那个在结构上成为默认:`agent/session-prefix` 的贡献在每个循环实例中只组合一次并逐字复用,因此以零边际成本扩展可缓存前缀,且不可能在会话中途击穿提供方缓存;会话中途变化的内容通过仅追加的历史通道流入——`agent.inject()` 以及工具/prompt-submit 的 `additionalContexts`——每条都是持久的 `context/message`,付出一次代价后即被前缀缓存,代价是在历史和日志中累积。将会话冻结的开场内容路由到前缀,将变更通知路由到历史通道;逐步骤的仅限请求尾部槽位被有意放弃(无消费方,且持久追加覆盖了当前所有更新模式)。 +- 模型可见上下文使用已记录消息通道。`agent.inject()` 与工具 `additionalContexts` 进入 inbox,等待后续领取;必须与当前已领取批次一起结算的上下文由 `agent/pre-step` 返回。每个进入步骤的值都是带来源的持久 `user/message`,只付出一次代价并在后续成为可缓存前缀,代价是会在历史中累积直至压缩。 - 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compact/*` 事件和替换条目)、真正的提示词、工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。 -- `step/start` 监听器行为变更(见上文)是对插件唯一可观察的语义变更;`agent/pre-step` 是当前请求的 seam。 +- `agent/pre-step` 是当前请求的消息 seam;直接修改 inbox 则是最终进入后续请求的 seam。 - 工具结果裁剪(计划中)无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。 - 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 - 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml index 3d45205e46..7852bcf11e 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 04f11d0a2b33d1a2ddd9c782489622a4f9e76d13 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 981ed87864cc82821a411a3a0ac1f511e3ac514b +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 6fbd5e2c9d57da3f25c72c652ca50eb45b84323c +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 5f42a9926abc490274fbc0a180e46719166e441c diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md index 04f11d0a2b..6fbd5e2c9d 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md @@ -12,11 +12,11 @@ Successful calls are not the only pressure signal. A provider can reject a reque ## Decision -### Successful pressure moves to a durable post-step checkpoint +### Successful pressure runs at the next pre-step boundary -`agent/pre-step` is narrowed to `(agent, turn, step, signal)`. It remains a generic serial checkpoint before `step/start`, but it carries no compaction-only prompt or prefix fields. +`agent/pre-step` receives the exclusive claimed message batch plus `{ turn, step, signal }` and returns the final reject/enter decision. It carries no compaction-only prompt or prefix fields. -The loop fires awaited serial `agent/post-step(agent, turn, step, signal)` after assistant output, every dispatched or synthetic tool result, post-tool context, and steering are durable, but before `step/end`. This placement gives pressure policy the complete successful-call state without splitting an assistant tool call from its result. A propagated listener failure is an ordinary turn failure; it never enters model-request recovery. Compact-basic contains its expected operational failures as described below. +Compact-basic wraps `agent/pre-step` before each proposed request. At a continuation boundary the preceding assistant output, every dispatched or synthetic tool result, post-tool context, and steering are already durable, so pressure policy sees the complete successful-call state without splitting an assistant tool call from its result. At the initial boundary a headerless session has no completed routed request and produces no pressure work. Compact-basic contains operational failures, warns, and delegates without rejecting the proposed step. `dsh-compact-basic` reads the exact latest routed model from the durable request header only to establish that a completed route exists, then asks the singleton `ctx.tokenMeter` to measure the canonical logged envelope and current surface. It does not fall back to `AgentOptions.model` for automatic pressure. A headerless session has no completed routed request to assess and produces no work; any durable non-empty model name uses the same estimator. Operational measurement or summarization failures warn and continue from the latest durable surface: full history before any replacement, or the pruned surface if pruning already landed. @@ -46,7 +46,7 @@ Unit tests cover the final-adapter normalization boundary, closed-turn retry num ## Alternatives considered -- **Keep provisional pre-step pressure and add more arguments** — rejected because later routing and request mutation remain outside any earlier snapshot, while generic lifecycle becomes coupled to one plugin. +- **Add compaction-only fields to pre-step** — rejected because the canonical durable session and token meter already own the measurement input; the generic lifecycle need not carry a second envelope. - **Retry the same numbered step** — rejected because recovery appends durable events after the failed boundary. A new step preserves balanced nesting and reconstructability. - **Retry whenever `compactIfNeeded` returns a result** — rejected because a custom backend can report success without changing model-visible state. `replaceGeneration` is the authoritative proof. - **Let compact-basic parse provider wording** — rejected because classification belongs at adapters and must cover both thrown and in-band delivery. @@ -54,8 +54,8 @@ Unit tests cover the final-adapter normalization boundary, closed-turn retry num ## Consequences -Post-step pressure describes the completed routed request, including durable tool results and request-only prefix fields. Optional model-free pruning removes predictable tool-output bulk before summary selection and can independently create retry-worthy progress. Canonical overflow supplies the backstop when no successful usage anchor exists. Recovery is bounded, cancellation-owned, and monotonic: it retries only after a visible surface generation change. +The next pre-step pressure check describes the preceding completed routed request, including durable tool results and newly claimed input. Optional model-free pruning removes predictable tool-output bulk before summary selection and can independently create retry-worthy progress. Canonical overflow supplies the backstop when no successful usage anchor exists. Recovery is bounded, cancellation-owned, and monotonic: it retries only after a visible surface generation change. -The cost is one additional serial checkpoint on successful steps and adapter-maintained overflow classification. Provider wording and heuristic character density remain maintenance risks. Surface compaction still cannot repair an envelope that alone exceeds the window, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder remains oversized. The optional pruner can repair an otherwise indivisible tool pair when removable text-bearing tool-result content is the bulk. +The cost is pressure work in the shared pre-step waterfall and adapter-maintained overflow classification. Provider wording and heuristic character density remain maintenance risks. Surface compaction still cannot repair an envelope that alone exceeds the window, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder remains oversized. The optional pruner can repair an otherwise indivisible tool pair when removable text-bearing tool-result content is the bulk. -This Agent Note supersedes only the pre-step automatic-trigger portion of the [compaction capability-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md). The service split, standalone token meter, balanced range contract, log-recorded lock, summary replacement, and sole `summarize()` subclass hook remain unchanged. +The [claimed pre-step lifecycle](2026-07-31-claimed-pre-step-inbox-lifecycle.md) supersedes this note's former post-step trigger. The service split, standalone token meter, balanced range contract, log-recorded lock, summary replacement, and sole `summarize()` subclass hook remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md index 981ed87864..5f42a9926a 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -12,11 +12,11 @@ Status: implemented ## 决策 -### 成功压力移动到持久 post-step 检查点 +### 成功压力在下一个 pre-step 边界运行 -`agent/pre-step` 收窄为 `(agent, turn, step, signal)`。它仍是 `step/start` 之前的通用串行检查点,但不再携带压缩专用的提示词或前缀字段。 +`agent/pre-step` 接收独占的已领取消息批次与 `{ turn, step, signal }`,并返回最终 reject/enter 决策。它不携带压缩专用的提示词或前缀字段。 -循环在 assistant 输出、所有已分发或合成的工具结果、工具后上下文与 steering 都持久化之后、`step/end` 之前,触发等待式串行 `agent/post-step(agent, turn, step, signal)`。该位置让压力策略看到完整的成功调用状态,同时不会拆开 assistant 工具调用与其结果。向外传播的监听器失败属于普通 turn 失败,绝不会进入模型请求恢复;compact-basic 会按下文所述在内部处理其预期的操作性失败。 +Compact-basic 会在每个拟议请求之前包装 `agent/pre-step`。在续步边界,前一条 assistant 输出、所有已分发或合成的工具结果、工具后上下文与 steering 都已经持久化,因此压力策略能看到完整的成功调用状态,同时不会拆开 assistant 工具调用与其结果。初始边界上的无 header 会话尚无已完成路由请求,因此不执行压力工作。Compact-basic 会在内部处理操作性失败、发出警告并继续委托,不会 reject 拟议步骤。 `dsh-compact-basic` 从持久请求头读取精确的最新实际路由模型,只用它确认已经存在完整路由,随后让单例 `ctx.tokenMeter` 计量规范日志信封与当前表层。自动压力不会回退到 `AgentOptions.model`。没有请求头的会话尚无已完成路由请求可供判断,因此不执行工作;任意持久记录的非空模型名都使用同一个估算器。操作性的计量或摘要失败会发出警告,并从最新持久表层继续:任何替换发生前使用完整历史;若剪枝已经落盘,则使用已剪枝表层。 @@ -46,7 +46,7 @@ Status: implemented ## 考虑过的替代方案 -- **保留临时 pre-step 压力并增加更多参数**——不予采纳,因为后续路由与请求变换仍在更早快照之外,同时通用生命周期会耦合到单个插件。 +- **向 pre-step 增加压缩专用字段**——不予采纳,因为规范持久会话与 token meter 已拥有计量输入;通用生命周期不需要携带第二份信封。 - **重试相同编号的 step**——不予采纳,因为恢复会在失败边界之后追加持久事件。新 step 保持边界配对与可重建性。 - **只要 `compactIfNeeded` 返回结果就重试**——不予采纳,因为自定义后端可能报告成功却没有改变模型可见状态。`replaceGeneration` 才是权威证明。 - **让 compact-basic 解析提供方措辞**——不予采纳,因为分类属于适配器,而且必须同时覆盖抛出式与带内交付。 @@ -54,8 +54,8 @@ Status: implemented ## 后果 -Post-step 压力描述已完成的路由请求,包括持久工具结果与仅请求前缀字段。可选的无模型剪枝会在选择摘要前移除可预测的工具输出体积,也能独立产生足以重试的进展。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有明确上限、以取消为准,并保持单调:只有模型可见的表层 generation 变化后才重试。 +下一个 pre-step 的压力检查描述前一个已完成的路由请求,包括持久工具结果与新领取输入。可选的无模型剪枝会在选择摘要前移除可预测的工具输出体积,也能独立产生足以重试的进展。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有明确上限、以取消为准,并保持单调:只有模型可见的表层 generation 变化后才重试。 -代价是成功 step 增加一个串行检查点,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分不可分割的非工具节点,或修复非可剪枝剩余部分仍然过大的工具单元。若可移除的文本工具结果是主要体积,可选剪枝器仍可修复原本不可分割的工具配对。 +代价是在共享 pre-step waterfall 中执行压力工作,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分不可分割的非工具节点,或修复非可剪枝剩余部分仍然过大的工具单元。若可移除的文本工具结果是主要体积,可选剪枝器仍可修复原本不可分割的工具配对。 -本 Agent Note 只取代[压缩能力接缝 Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 中的 pre-step 自动触发部分。服务拆分、独立 token meter、平衡范围契约、日志记录锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。 +[已领取 pre-step 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md)取代了本记录原先的 post-step 触发方式。服务拆分、独立 token meter、平衡范围契约、日志记录锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index cbaedf3ab9..db22ff80b4 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml @@ -1,6 +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-12-agent-scope-runtime-design.md: 232fc02d66411b5ee8a21943795a3be4713bf238 -2026-07-12-agent-scope-runtime-design.zh.md: 39d558f8cde0183a3590d268aca36ea85e5f5c63 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +2026-07-12-agent-scope-runtime-design.md: eea41a3f4bd9465e2e89cb8c67ff7ec412bade71 +2026-07-12-agent-scope-runtime-design.zh.md: 770712085e17f9de2a0fdb1fba077d2048d7cc0f diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 232fc02d66..eea41a3f4b 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -150,7 +150,7 @@ sequenceDiagram Every teardown request joins one memoized path. The order is: 1. Deactivate creation or driving and let synchronous publication finish. -2. Stop and drain the driver, including idle injection flushes. +2. Stop and drain the driver, discarding any injection that remains pending. 3. Detach the agent. 4. Detach the session. 5. Dispose the agent scope. @@ -238,7 +238,7 @@ For a native call, the observer deletes the stage and commits its value only whe For a Code Mode SDK call, the inner successful result records `{ parentToken, value }` rather than committing. The observer waits for the `run_code` execution whose token matches `parentToken` and commits only if that outer final result also succeeds. Program failure, runtime abort, or outer post-policy denial discards the pending value. -Once a value is pending or committed, a scoped monotonic guard denies later tool calls. After commit, the ordinary serial `agent/turn-stop` listener returns a stop decision after continuation and steering have already folded. A schema-validation failure remains an ordinary `INVALID_ARGS` tool error and leaves the child able to retry within the same turn. +Once a value is pending or committed, a scoped monotonic guard denies later tool calls. The successful structured-output execution calls `exec.concludeTurn()`, so its own immutable result carries `concludesTurn: true` and the loop ends the tool loop at that step. A schema-validation failure remains an ordinary `INVALID_ARGS` tool error and leaves the child able to retry within the same turn. Pure Code Mode's registry contribution omits `structured_output` from native wire schemas and exposes it through the generated SDK. The assembly waterfall may deliberately change that presentation; execution still validates against the child-scoped definition, and the listener owns the consistency of any alternate model-visible route it creates. @@ -250,9 +250,9 @@ Prompt assembly is intentionally cooperative, but three execution facts need one |---|---|---| | Tool pre-policy | Deny monotonically | A later listener must not re-allow an already denied call | | Tool result | Observe the immutable committed outcome | Structured output must commit only the result that actually escaped the pipeline | -| Turn continuation | Stop after ordinary continuation folding | A committed terminal output must end the turn | +| Turn continuation | Conclude through the committed tool result | A committed terminal output must end the turn | -`ToolGuard` is the monotonic policy registry. Committed tool observation is the contained `tools/result` point described above. Terminal structured output listens on the ordinary serial `agent/turn-stop` fold after normal continuation and steering decisions; no public `strictSerial()` dispatcher is needed for the typed listener contract. +`ToolGuard` is the monotonic policy registry. Committed tool observation is the contained `tools/result` point described above. Terminal structured output marks its own execution with `concludesTurn`, so terminality is data on the authoritative result rather than a separate hook decision. ### Skill and approval services trust typed callers diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index 39d558f8cd..770712085e 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -150,7 +150,7 @@ sequenceDiagram 每个拆除请求加入一条记忆化路径。顺序为: 1. 停用创建或驱动,让同步发布完成。 -2. 停止并排空 driver,包括空闲注入刷新。 +2. 停止并排空 driver,丢弃仍处于待处理状态的注入。 3. 分离 agent。 4. 分离会话。 5. Dispose agent 作用域。 @@ -238,7 +238,7 @@ Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子 对于 Code Mode SDK 调用,内层成功结果记录 `{ parentToken, value }` 而非提交。观察者等待 token 匹配 `parentToken` 的 `run_code` 执行,仅在该外层最终结果也成功时才提交。程序失败、运行时中止或外层 post-policy 拒绝会丢弃待定值。 -一旦值处于待定或已提交状态,作用域单调守卫拒绝后续工具调用。提交后,普通串行的 `agent/turn-stop` 监听器在 continuation 和 steering(中途引导)已折叠之后返回停止决策。Schema 验证失败仍然是普通的 `INVALID_ARGS` 工具错误,子级可以在同一轮次内重试。 +一旦值处于待定或已提交状态,作用域单调守卫拒绝后续工具调用。成功的结构化输出执行会调用 `exec.concludeTurn()`,因此其自身不可变结果携带 `concludesTurn: true`,循环在该步骤结束工具循环。Schema 验证失败仍然是普通的 `INVALID_ARGS` 工具错误,子级可以在同一轮次内重试。 纯 Code Mode 的注册表贡献从原生 wire schema 中省略 `structured_output`,并通过生成的 SDK 暴露它。Assembly waterfall 可以有意改变该展示;执行仍然针对子作用域定义进行验证,监听器拥有其创建的任何替代模型可见路由的一致性。 @@ -250,9 +250,9 @@ Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子 |---|---|---| | 工具 pre-policy | 单调拒绝 | 后续监听器不得重新允许已被拒绝的调用 | | 工具结果 | 观察不可变的已提交结果 | 结构化输出必须仅提交实际逃出流水线的结果 | -| 轮次 continuation | 在普通 continuation 折叠之后停止 | 已提交的终端输出必须结束轮次 | +| 轮次 continuation | 通过已提交工具结果终止 | 已提交的终端输出必须结束轮次 | -`ToolGuard` 是单调策略注册表。已提交的工具观察是上述被隔离的 `tools/result` 点。终端结构化输出监听普通串行的 `agent/turn-stop` 折叠,在正常 continuation 和 steering 决策之后;类型化的监听器契约不需要公开的 `strictSerial()` dispatcher。 +`ToolGuard` 是单调策略注册表。已提交的工具观察是上述被隔离的 `tools/result` 点。终端结构化输出在自身执行上标记 `concludesTurn`,因此终止性成为权威结果上的数据,而不是独立 hook 决策。 ### Skill 和 approval 服务信任类型化调用方 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml index c34acd2db1..f962edd22b 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-14-provider-routed-llm-adapters.md: 1bd9197667f6e49c5025c98b4a77500f78595c2b -2026-07-14-provider-routed-llm-adapters.zh.md: 4d57f2cb33ac296500a4a19771ea493621ff93f6 +2026-07-14-provider-routed-llm-adapters.md: 27277280e423553f79d5a34f512b673413f495ff +2026-07-14-provider-routed-llm-adapters.zh.md: 63a6aaf4453c0d8cfb6a23bfed4a94dacb054da0 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md index 1bd9197667..27277280e4 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md @@ -40,7 +40,7 @@ pi-ai's common stream options do not expose stop sequences. `dsh-llm-pi-ai` reje Assistant messages carry provider-neutral provenance containing the request's `provider` and `model`, plus an optional JSON-serializable adapter replay state. A successful `assistant/message` session event records this provenance and `deriveMessages()` returns it with the assistant message. User, system, context, and tool-result messages carry no assistant provenance. The provider/model fields are authoritative loop data; an adapter owns only its opaque replay-state payload. -A terminal successful `finish` chunk may carry replay state, and `BlockAssembler` retains it alongside usage and finish reason. The loop attaches it to the assistant provenance only when the post-`agent/step-result` content is structurally equal to the assembled provider output. A listener that rewrites content keeps the provider/model provenance but loses the now-stale replay state. Error and aborted responses do not produce a normal assistant message and therefore do not enter future model history. +A terminal successful `finish` chunk may carry replay state, and `BlockAssembler` retains it alongside usage and finish reason. The loop attaches that state to the assembled assistant provenance without exposing a response-rewrite hook. Error and aborted responses do not produce a normal assistant message and therefore do not enter future model history. The pi-ai replay state is a versioned, minimal projection of its successful `AssistantMessage`: source API/provider/model, response id/model, stop reason, and index-aligned text, thinking, and tool-call signatures. It does not duplicate text or tool arguments already carried by Harness content blocks, and it omits diagnostics, timestamps, usage, and errors. On a later request, `LlmService` gives replay state to the target adapter only when the historical provider and target provider are currently owned by the same adapter instance. That adapter combines the logged Harness content with replay state when it can restore the historical response, and owns any required cross-model or cross-provider conversion. An adapter receiving replay state with an unknown version or mismatched block shape fails explicitly; a different adapter receives only provider-neutral content and provenance. diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md index 4d57f2cb33..63a6aaf445 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md @@ -40,7 +40,7 @@ pi-ai 的通用流选项不支持停止序列。若 Harness `stop` 选项已定 助手消息携带提供方无关的来源信息,其中包含请求的 `provider` 和 `model`,以及可选的 JSON 可序列化适配器回放状态。成功的 `assistant/message` 会话事件记录这些来源信息,`deriveMessages()` 返回助手消息时也会包含这些信息。用户、system、context 与工具结果消息不携带助手来源信息。provider/model 字段是 agent loop 的权威数据;适配器仅拥有其不透明回放状态 payload。 -成功的终止 `finish` 分片可以携带回放状态,`BlockAssembler` 会将其与 token 用量和结束原因一起保留。只有当 `agent/step-result` 处理后的内容与提供方组装输出在结构上相等时,agent loop 才会把回放状态附加到助手来源信息。监听器重写内容后,provider/model 来源信息仍会保留,但已经陈旧的回放状态会被移除。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。 +成功的终止 `finish` 分片可以携带回放状态,`BlockAssembler` 会将其与 token 用量和结束原因一起保留。agent loop 会把该状态附加到组装后的助手来源信息,不再暴露响应改写 hook。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。 pi-ai 回放状态是其成功 `AssistantMessage` 的带版本最小投影,包含源 API/provider/model、响应 ID/model、停止原因,以及按索引对齐的文本、thinking 和工具调用签名。它不会重复 Harness 内容块中已有的文本或工具参数,也不包含诊断信息、时间戳、用量或错误。后续请求中,只有历史提供方和目标提供方当前归同一个适配器实例所有时,`LlmService` 才会把回放状态交给目标适配器。适配器在能够恢复历史响应时,将 Harness 记录的内容与回放状态组合,并负责所需的跨模型或跨提供方转换。适配器收到未知版本或块形状不匹配的回放状态时会显式失败;其他适配器只能收到提供方无关的内容与来源信息。 diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml index dd16a5f327..e52d97e6ae 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-replay-token-meter-service.md: 3496364663c1f73b8161461d1a229b19d9730c6d -2026-07-15-replay-token-meter-service.zh.md: 0bc4d9decac36bd5674cd0fb04f82fdcd277554e +2026-07-15-replay-token-meter-service.md: c0f4b467ad0013dd4ac0a0301281b011ea8c261c +2026-07-15-replay-token-meter-service.zh.md: 0d3d6c7ff9be035636d3a2cba63cb59d1489cdab diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md index 3496364663..c0f4b467ad 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md @@ -36,7 +36,7 @@ Automatic compaction uses one unified measurement for each threshold-and-retenti Compact policy has service-wide defaults: threshold ratio `0.8`, retained-tail ratio `0.16`, `summarizationProvider: ''`, `summarizationModel: ''`, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Top-level fields apply to every routed target; exact provider/model entries in `modelPolicies` partially override them. Pressure scales ratios against capacity resolved from the owning adapter, and `retainTokens` may replace `retainRatio`; retention must remain below the resulting threshold. The summarization provider and model must both be set or both be empty; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. -Automatic pressure runs at `agent/post-step` and measures the canonical durable envelope produced under the provider/model actually selected by `agent/request`. A headerless session has no completed routed request to assess and produces no work; any routed target can use the singleton estimator. Canonical overflow recovery uses the same measurement for forced range selection and retries only after a proven surface replacement. +Automatic pressure runs at `agent/pre-step` before request derivation and measures the canonical durable envelope produced under the provider/model actually selected by the preceding `agent/request`. A headerless session has no completed routed request to assess and produces no work; any routed target can use the singleton estimator. Canonical overflow recovery uses the same measurement for forced range selection and retries only after a proven surface replacement. ## Testing diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md index 0bc4d9deca..0d3d6c7ff9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md @@ -36,7 +36,7 @@ Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket 压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部比例 `0.16`、`summarizationProvider: ''`、`summarizationModel: ''`、`maxTokens: 8192`、`compactionRetries: 1`、`maxOverflowRetries: 1` 与 `auto: true`。顶层字段适用于每个路由目标;`modelPolicies` 中的精确提供方/模型项可以部分覆盖这些字段。压力检查根据所属适配器解析的容量缩放比例,`retainTokens` 可以替代 `retainRatio`;保留值必须小于最终阈值。摘要提供方与模型必须同时设置或同时为空;空组合先解析最近记录的请求目标,再使用 `AgentOptions` 中的组合。 -自动压力检查运行在 `agent/post-step`,并计量 `agent/request` 实际所选提供方/模型产生的规范持久信封。没有请求头的会话尚无已完成的路由请求可供判断,因此不执行工作;任意路由目标都可使用这个单例估算器。规范化溢出恢复使用同一计量结果强制选择范围,并且只有在表层替换得到证明后才重试。 +自动压力检查在请求派生前运行于 `agent/pre-step`,并计量前一个 `agent/request` 实际所选提供方/模型产生的规范持久信封。没有请求头的会话尚无已完成的路由请求可供判断,因此不执行工作;任意路由目标都可使用这个单例估算器。规范化溢出恢复使用同一计量结果强制选择范围,并且只有在表层替换得到证明后才重试。 ## 测试 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 11c3d9b5a2..867c15040d 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-16-explicit-turn-cancellation.md: 15085a1da2cf183bace9957a4bedb3ea466aa472 -2026-07-16-explicit-turn-cancellation.zh.md: e945b0fea51bdbfee38048573c643b0fb8ecb685 +2026-07-16-explicit-turn-cancellation.md: cce649976c9f4f596d5306b9fe8c3fd49a0e1adc +2026-07-16-explicit-turn-cancellation.zh.md: 7132ec1d15ff708460a176bb9ba5b22e0f358577 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index 15085a1da2..cce649976c 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -16,11 +16,11 @@ Agent owns the runtime-only `AgentCancelCause` union `{ kind: 'user' } | { kind: An interrupted live turn ends with the coarse durable `{ kind: 'aborted' }` outcome. The terminal event records what happened to the turn, while the runtime signal identifies who requested cancellation; it does not duplicate `user` or `parent` into replay. Session seed/load rejects legacy aborted records with a reason or any other extra field, so replay cannot reintroduce caller-owned cancellation detail. The process-local `agent/cancel-requested` notification is not durable; a future audit requirement uses a separate durable control-request event so a request and its eventual outcome remain distinct. Durable events contain no stack, signal, error object, free-form cancellation text, or backend-private detail. -AgentLoop privately owns one `TurnCancellation` per prospective turn. It installs the holder before notifying `agent/status = running`, retains its single `AbortController` through prompt processing, prompt assembly, every step, model and tool execution, continuation, and `agent/turn-stop`, then clears the exact holder immediately before publishing `turn/end`. Terminal event observers and the following durability flush therefore cannot cancel already-completed turn work even though driver status may remain `running` until the flush settles. Every participating method, event, and request value receives that same explicit signal; the next turn receives a fresh signal. +AgentLoop privately owns one `TurnCancellation` per prospective turn. It installs the holder before notifying `agent/status = running`, retains its single `AbortController` through inbox claim, `agent/pre-step`, prompt assembly, every step, model and tool execution, and `agent/turn-stopping`, then clears the exact holder immediately before publishing `turn/end`. Terminal event observers and the following durability flush therefore cannot cancel already-completed turn work even though driver status may remain `running` until the flush settles. Every participating method, event, and request value receives that same explicit signal; the next turn receives a fresh signal. The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work. -The explicit event signatures keep their positional form and place `signal` immediately before a waterfall's final `next`. Prompt submission, request configuration, step-result processing, continuation, and terminal stop join the pre-existing explicit signal seams for pre-step, session prefix, model generation, tool execution, approval, and subagent or workflow requests. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. +The explicit event signatures keep their positional form and place `signal` inside `PreStepContext` or immediately before a waterfall's final `next`. Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. `ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. @@ -30,7 +30,7 @@ Cancellation remains cooperative. The loop checks interruption before and after ## Verification -Contract tests verify the typed caller union, frozen detachment, default and first-wins behavior, the coarse Session JSON round trip and legacy-record rejection, ACP `user`, in-process subagent `parent`, and disposal precedence. Loop tests make cooperative listeners wait on the signal at prompt submission, system-prompt assembly, session prefix, pre-step, request, model stream, step result, tool execution, continuation, and terminal stop; they assert one signal within a turn, a fresh signal across turns, and no cancellation authority during terminal publication or a blocked durability flush. A real hook bridge test cancels and reaps a blocked prompt hook before idle. +Contract tests verify the typed caller union, frozen detachment, default and first-wins behavior, the coarse Session JSON round trip and legacy-record rejection, ACP `user`, in-process subagent `parent`, and disposal precedence. Loop tests make cooperative listeners wait on the signal at pre-step, system-prompt assembly, request, model stream, request-error recovery, tool execution, and turn stopping; they assert one signal within a turn, a fresh signal across turns, and no cancellation authority during terminal publication or a blocked durability flush. A real hook bridge test cancels and reaps a blocked prompt hook before idle. Initiator-scope tests assert that every hook still observes the exact Agent and no ambient turn signal, concurrent Agents retain independent identities and signals, and a nested child driver shadows only identity. Race tests cover idle cancellation, pre-run cancellation, replacement submission from a `running` listener, repeated cancellation, and cancel-versus-dispose quiescence. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index e945b0fea5..7132ec1d15 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -16,11 +16,11 @@ Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user 正在运行的轮次被中断后,以粗粒度的持久化结果 `{ kind: 'aborted' }` 结束。终态事件记录轮次发生了什么,运行时 signal 标识谁请求了取消;回放不会重复保存 `user` 或 `parent`。Session seed/load 会拒绝携带取消原因或任何其他额外字段的旧式中止记录,因此回放无法重新引入由调用方持有的取消细节。仅限进程内的 `agent/cancel-requested` 通知不会持久化;未来若有审计需求,应使用独立的持久化控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含调用栈、signal、错误对象、自由文本取消原因或后端私有细节。 -AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖提示词处理、提示词组装、每个步骤、模型与工具执行、继续决策和 `agent/turn-stop`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。 +AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖 inbox 领取、`agent/pre-step`、提示词组装、每个步骤、模型与工具执行以及 `agent/turn-stopping`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。 对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 -显式事件签名保留位置参数形式,并把 `signal` 放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。提示词提交、请求配置、步骤结果处理、继续决策和终止停止加入已有的步骤前处理、会话前缀、模型生成、工具执行、审批以及 subagent 或工作流请求的显式 signal seam。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 +显式事件签名保留位置参数形式,并把 `signal` 放入 `PreStepContext`,或放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 `ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 @@ -30,7 +30,7 @@ Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时 ## 验证 -契约测试验证类型化调用方联合类型、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在提示词提交、系统提示词组装、会话前缀、步骤前处理、请求、模型流、步骤结果、工具执行、继续决策和终止停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。 +契约测试验证类型化调用方联合类型、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在 pre-step、系统提示词组装、请求、模型流、请求错误恢复、工具执行和轮次停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。 发起方作用域测试断言所有钩子仍观察到同一个 Agent 且没有环境中的轮次 signal,并发 Agent 保持独立的身份与 signal,嵌套子驱动只遮蔽身份。竞态测试覆盖空闲状态取消、运行前取消、从 `running` 监听器提交替代提示词、重复取消以及取消与 dispose 竞争下的静止状态。 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index dfae9677c4..deb1001f22 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md -2026-07-22-unified-send-and-coalesced-user-messages.md: 4d0cbeff0c8a07362caa1ec18493267a9f0d2823 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 935a1a78a6bed451c1db646dec2ec5f4f5e87949 +2026-07-22-unified-send-and-coalesced-user-messages.md: 4bc4303f636ea7ff268d0b3f2d0a65dfba954883 +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 4dc725d5d03f1871c7fb5115577e3283bb395add diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 4d0cbeff0c..4bc4303f63 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -12,9 +12,9 @@ Separately, `context/message` and `user/message` had converged: the surface proj ## Decision -**One primitive, three preset aliases.** The `Agent` interface's `send(message, { target, wakeup })` covers the (`target` × `wakeup`) matrix. Its complete `UserMessage` owns identity, role, model-facing `content`, and producer `source`; the complete `SendOptions` owns only routing policy. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) each accept that one message and fix the policy. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `next-turn`/no-wakeup (queue without waking) is representable with no alias and no current caller. +**One primitive, three preset aliases.** The `Agent` interface's `send(message, target, wakeup)` covers the (`target` × `wakeup`) matrix. Its complete `UserMessage` owns identity, role, model-facing `content`, and producer `source`; the remaining arguments own only routing policy. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) each accept that one message and fix the policy. `wakeup` reserves a driver when the agent is idle; an already active driver receives no second reservation and can claim the input only if it reaches a later pre-step boundary. `next-turn`/no-wakeup (queue without waking) is representable with no alias and no current caller. -**inject keeps its mechanism.** The `next-step`/no-wakeup path is exactly the old `inject`: durable model-facing context appended at the current log position, deferred while prompt admission or a turn owns the next safe boundary, and appended directly outside that window. It bypasses the FIFOs entirely, while its required `UserMessage.source` preserves the caller's explicit provenance. +**inject is a non-waking next-step delivery.** It always appends the complete message to the next-step inbox. The driver claims it at a later pre-step and records it only when the final decision returns it in the entering batch; idle injection remains pending until another delivery wakes the driver. Its required `UserMessage.source` preserves the caller's explicit provenance. **context/message is gone.** Injected context is now a `user/message`; context producers supply the appropriate non-user `source` explicitly, and typed source variants carry any domain-specific durable provenance. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. @@ -22,13 +22,13 @@ Separately, `context/message` and `user/message` had converged: the surface proj **`send` does not return identity.** Callers already own the complete message and its opaque `MessageId`; creation and freezing are owned by the [identified immutable message decision](2026-07-28-identified-immutable-message-values.md), not by routing. -**Inbox lifecycle events carry occurrence identities.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/update` (a pending queued item was edited), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (pending items were dropped) carry an `InboxItem`: an occurrence-local `InboxItemId`, the accepted `UserMessage`, and the resolved `queued | steering` placement captured at acceptance. The occurrence identity lets observers and reconnect mirrors distinguish repeated sends of the same `MessageId` without reconstructing routing from later status or session history. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes one enqueue and exactly one terminal dequeue or discard; updates are non-terminal. The `dsh-agent` invariant companion asserts this FIFO conservation. +**Inbox mutations have one durable projection and three minimal live notifications.** Every append, prepend, edit, remove, cancellation, and claim records normalized `agent/inbox/spliced` coordinates. Insertions emit `agent/inbox/inserted { message }`; ordinary removals carry durable `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; the loop's atomic `claim()` records pure deletion splices and then emits `agent/inbox/claimed { message, turn }`. `MessageId` is the sole occurrence identity and remains unique across both pending lists. The live payloads deliberately omit placement, outcome, and batch envelopes because the durable splice owns those facts. -**Admission accepts next-step input without becoming a turn.** The loop opens a private next-step acceptance window before `agent/prompt-submit`, keeps it open through the turn, and closes it before `turn/end`. Steering and injection received during admission therefore remain together in the outbox and join an allowed turn. If admission blocks or fails, a context-only caller batch takes idle injection's immediate append, while steering and context staged beside it remain available to retry; neither path writes the rejected prompt. When a later prompt is admitted, retained outbox input enters its turn before that prompt, while input accepted during the current admission remains after the prompt. Closing the window before `turn/end` preserves the rule that reentrant late steering becomes an independent queued turn. `Agent.acceptsNextStep` exposes whether a `next-step` send would currently join this window; `status` remains the broader activity signal rather than a routing predicate. +**Pre-step claims next-step input without making it a separate turn.** Steering and injection always enter the same next-step inbox; steering wakes the driver, while injection does not. At a turn boundary the driver atomically claims pending next-step input before one queued prompt, and between steps it claims only next-step input. Claiming records pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message. `agent/pre-step` then rejects the proposed step or returns its complete entering batch. Rejection and listener failure leave the claimed batch removed; input arriving after the claim waits for a later boundary. -**One accepted message keeps one representation.** Durable user-role input and additional model-facing context both use the identified, frozen `UserMessage` directly. The loop stores that value beside private routing state rather than copying its identity, content, or source into another public shape. A queued message that becomes steering keeps the same message value in the outbox, while injected and tool-produced context each carry their own identified message. The [identified immutable message decision](2026-07-28-identified-immutable-message-values.md) supersedes this note's former `UserMessageData`/`AgentMessage` hierarchy and extends the representation to assistant and tool-result messages. +**One accepted message keeps one representation.** Durable user-role input and additional model-facing context both use the identified, frozen `UserMessage` directly. The loop stores that value beside private routing state rather than copying its identity, content, or source into another public shape. Steering, injection, and tool-produced context each keep their identified messages in the next-step inbox. The [identified immutable message decision](2026-07-28-identified-immutable-message-values.md) supersedes this note's former `UserMessageData`/`AgentMessage` hierarchy and extends the representation to assistant and tool-result messages. -**Idle wakeup follows acceptance.** Before publishing enqueue, a waking queued send installs quiescence ownership and schedules driver admission for a microtask that runs after the id returns. Every send in one synchronous caller stack therefore resolves placement against the same pre-admission state, while reentrant cancellation or teardown cannot retire before the scheduled admission settles. Two idle `steer()` calls remain two FIFO turns instead of the first opening an admission window that captures the second. +**Idle wakeup follows insertion.** A waking send reserves the driver and schedules pre-step processing for a microtask after the input enters its target inbox. Every send in one synchronous caller stack therefore enters before claiming starts, while reentrant cancellation or teardown cannot retire before the scheduled pre-step settles. Multiple idle `steer()` calls in that stack form one next-step batch. **cancel gains keepInbox.** `cancel(cause, { keepInbox? })`; callers choose the cause explicitly, and `keepInbox: true` aborts the active turn while preserving queued and steering items (no discard event, and un-started work is not dropped). @@ -36,14 +36,14 @@ Separately, `context/message` and `user/message` had converged: the surface proj - **A dedicated `MessageSource` kind `context`** for injected content. Rejected because `plugin` already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Plugin-produced injected context supplies its plugin source explicitly. - **A typed discriminant field on `UserMessage`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact. -- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the resolved placement, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe. -- **Derive inbox placement from agent status or the session log.** Rejected because `running` includes admission and settlement, while reconnect baselines need the original acceptance result even when the earlier turn boundary is absent. The producer already owns the exact routing decision. +- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/inserted` is the live insertion signal, while claimed/discarded notifications describe exits and the durable splice retains placement. +- **Derive inbox placement from agent status.** Rejected because `running` includes pre-step processing and settlement. The producer already supplies the exact target to the durable splice. ## Consequences -The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The `Agent` contract remains an interface, so alternate implementations and object-literal test fakes implement the same minimal structural surface. The goal fold's channel split moved from event type to `source.round`, and every consumer that filtered `context/message` now filters `user/message` by source. An idle injection appends `user/message` between turns without opening a turn or running the model. +The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The `Agent` contract remains an interface, so alternate implementations and object-literal test fakes implement the same minimal structural surface. The goal fold's channel split moved from event type to `source.round`, and every consumer that filtered `context/message` now filters `user/message` by source. An idle injection remains pending without opening a turn or running the model, then becomes `user/message` when a later waking delivery's pre-step returns it in the entering batch. -`wakeup` is the "should the model run" signal, so the inbox distinguishes waking queued work from anything available to dequeue: a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. Every FIFO exit publishes exactly one lifecycle event, while domain-specific durable facts travel in typed message sources rather than a parallel metadata channel. The direct pending-item representation keeps public lifecycle events correlated without maintaining a second steering wrapper or allowing its durable data to diverge. The later [addressable queue operations](../feature/2026-07-29-addressable-queue-operations.md) decision adds live mutations over that occurrence identity without changing the one-message-per-turn or durable-message contracts. +`wakeup` is the "should the model run" signal, so the inbox distinguishes waking queued work from anything available to claim: a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. Every insertion and exit publishes its matching live notification, while domain-specific durable facts travel in typed message sources rather than a parallel metadata channel. The direct pending-message representation keeps durable splices and live events correlated without maintaining a second steering wrapper or allowing its data to diverge. The later [claimed pre-step inbox lifecycle](2026-07-31-claimed-pre-step-inbox-lifecycle.md) decision keeps live queue mutations addressed by `MessageId` and separates single-message lifecycle notifications from the durable whole-queue splice projection. ## Related diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 935a1a78a6..4dc725d5d0 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -12,9 +12,9 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` ## 决策 -**一个原语,三个预设别名。** `Agent` 接口的 `send(message, { target, wakeup })` 覆盖 (`target` × `wakeup`) 矩阵。完整的 `UserMessage` 持有标识、角色、模型可见 `content` 与生产方 `source`;完整的 `SendOptions` 只持有路由策略。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)都接收这一条消息并固定策略。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`next-turn`/no-wakeup(入队但不唤醒)可以表达,只是没有别名,也没有当前调用方。 +**一个原语,三个预设别名。** `Agent` 接口的 `send(message, target, wakeup)` 覆盖 (`target` × `wakeup`) 矩阵。完整的 `UserMessage` 持有标识、角色、模型可见 `content` 与生产方 `source`;其余参数只持有路由策略。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)都接收这一条消息并固定策略。`wakeup` 会在 agent 空闲时保留一个驱动器;已经活跃的驱动器不会获得第二次保留,只有在抵达后续 pre-step 边界时才能领取该输入。`next-turn`/no-wakeup(入队但不唤醒)可以表达,只是没有别名,也没有当前调用方。 -**inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:持久的面向模型上下文会追加到当前日志位置;提示词准入或一个轮次占有下一个安全边界时,它会延迟处理,而在该窗口之外则直接追加。它完全绕过 FIFO 队列,而必填的 `UserMessage.source` 会保留调用方显式提供的来源信息。 +**inject 是不会唤醒的 next-step 投递。** 它始终把完整消息追加到 next-step inbox。驱动器会在后续 pre-step 领取它,并且只有最终决策把它放入进入步骤的批次时才会记录;空闲注入会保持待处理,直到其他投递唤醒驱动器。必填的 `UserMessage.source` 会保留调用方显式提供的来源信息。 **context/message 已移除。** 注入的上下文现在是一条 `user/message`;上下文生产方显式提供合适的非 `user` 类别 `source`,类型化 source 变体携带所有特定于领域的持久来源信息。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。 @@ -22,13 +22,13 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` **`send` 不返回标识。** 调用方已经持有完整消息及其不透明的 `MessageId`;消息的创建与冻结由[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)负责,而不是由路由负责。 -**Inbox 生命周期事件携带单次入队标识。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/update`(待处理的 queued 项被编辑)、`agent/inbox/dequeue`(驱动器认领一个项)和 `agent/inbox/discard`(待处理项被丢弃)都会携带一个 `InboxItem`:仅属于本次入队的 `InboxItemId`、已接受的 `UserMessage`,以及生产方在接受消息时捕获的已解析 `queued | steering` 放置方式。单次入队标识让观察方和重连镜像能够区分同一 `MessageId` 的多次发送,无需根据后续状态或会话历史重建路由。注入从不触及 FIFO,也不发出这些事件中的任何一个。每次 FIFO 入队都会发布一个 enqueue,并且恰好发布一个终态 dequeue 或 discard;update 不是终态。`dsh-agent` 的不变量配套断言这种 FIFO 守恒。 +**Inbox 变更只有一份持久投影和三种最小实时通知。** 每次 append、prepend、编辑、删除、取消与领取都会记录规范化的 `agent/inbox/spliced` 坐标。插入会发出 `agent/inbox/inserted { message }`;普通删除携带持久 `outcome: 'canceled'`,并发出 `agent/inbox/discarded { message }`;循环的原子 `claim()` 会记录纯删除 splice,随后发出 `agent/inbox/claimed { message, turn }`。`MessageId` 是唯一的单次出现标识,并在两个待处理列表间保持唯一。实时载荷刻意不携带 placement、outcome 或批次封套,因为这些事实由持久 splice 持有。 -**准入接受 next-step 输入,但不会因此成为一个轮次。** 循环会在 `agent/prompt-submit` 前打开一个私有的 next-step 接受窗口,使其贯穿整个轮次,并在 `turn/end` 前关闭。因此,在准入期间收到的 steering 和注入会一起留在 outbox 中并加入获准轮次。如果准入被阻止或失败,仅含调用方上下文的批次会采用空闲注入的立即追加行为,而 steering 及与其一同暂存的上下文仍可重试;两种路径都不会写入被拒绝的提示词。后续提示词获准时,保留在 outbox 中的输入会先于该提示词进入其轮次,而当前准入期间接受的输入则留在提示词之后。在 `turn/end` 前关闭窗口,可以保留这样的规则:可重入的晚到 steering 会成为一个独立的排队轮次。`Agent.acceptsNextStep` 会公开一次 `next-step` 发送当前是否会加入该窗口;`status` 仍是更宽泛的活动信号,而非路由判据。 +**pre-step 会领取 next-step 输入,但不会为它单独创建轮次。** steering 和注入始终进入同一个 next-step inbox;steering 会唤醒驱动器,注入则不会。在轮次边界,驱动器会原子领取待处理的 next-step 输入,再领取一条排队提示词;在步骤之间则只领取 next-step 输入。领取会记录纯删除 splice,并针对每条消息发出一次 `agent/inbox/claimed { message, turn }`。随后 `agent/pre-step` 会拒绝拟议步骤,或返回进入步骤的完整批次。reject 与监听器失败都会让已领取批次保持已删除;领取后才到达的输入会等待后续边界。 -**一条已接受消息只保留一种表示。** 持久的用户角色输入和附加的模型可见上下文都直接使用带标识且冻结的 `UserMessage`。循环把该值与私有路由状态存放在一起,不会将其标识、内容或来源复制到另一种公开形状中。一条成为 steering 的排队消息会在 outbox 中保留同一个消息值,而注入和工具产生的上下文则各自携带带标识的消息。[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)取代了本记录此前的 `UserMessageData`/`AgentMessage` 层级,并将这一表示扩展到 assistant 消息和工具结果消息。 +**一条已接受消息只保留一种表示。** 持久的用户角色输入和附加的模型可见上下文都直接使用带标识且冻结的 `UserMessage`。循环把该值与私有路由状态存放在一起,不会将其标识、内容或来源复制到另一种公开形状中。steering、注入和工具产生的上下文都会在 next-step inbox 中保留各自带标识的消息。[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)取代了本记录此前的 `UserMessageData`/`AgentMessage` 层级,并将这一表示扩展到 assistant 消息和工具结果消息。 -**空闲唤醒在接受之后发生。** 在发布 enqueue 前,一次会唤醒驱动器的排队发送会先取得完全停稳所有权,并把驱动器准入调度到一个会在该次发送返回 id 后运行的微任务中。因此,同一同步调用栈中的每次发送都会基于同一份准入前状态解析放置方式,而可重入的取消或拆除在已调度的准入结算前无法完成退役。空闲时的两次 `steer()` 调用会保留为两个 FIFO 轮次,而不会因第一次调用打开准入窗口而把第二次吸纳进去。 +**空闲唤醒在插入之后发生。** 会唤醒的发送会先保留驱动器,并在输入进入目标 inbox 后把 pre-step 处理调度到微任务。因此,同一同步调用栈中的每次发送都会在领取开始前进入 inbox,而可重入的取消或拆除在已调度的 pre-step 结算前无法完成退役。同一调用栈中多次空闲 `steer()` 会形成一个 next-step 批次。 **cancel 新增 keepInbox。** `cancel(cause, { keepInbox? })`;调用方显式选择 cause,且 `keepInbox: true` 会中止活跃轮次,同时保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 @@ -36,14 +36,14 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` - **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。由插件产生的注入上下文会显式提供其 plugin 来源。 - **在 `UserMessage` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。 -- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是带有已解析的放置方式,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。 -- **根据 agent 状态或会话日志推导 inbox 放置方式。** 不予采纳,因为 `running` 同时涵盖准入与结算,而重连基线即使缺少此前的轮次边界,也需要最初的接受结果。生产方已经拥有精确的路由决策。 +- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/inserted` 已经是实时插入信号,claimed/discarded 通知描述退出,而持久 splice 保留 placement。 +- **根据 agent 状态推导 inbox 放置方式。** 不予采纳,因为 `running` 同时涵盖 pre-step 处理与结算。生产方已经把精确目标写入持久 splice。 ## 后果 -投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。`Agent` 契约仍是接口,因此其他实现和对象字面量形式的测试替身只需实现同一个最小结构接口。goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。空闲状态下的注入会在两个轮次之间追加 `user/message`,既不打开轮次,也不运行模型。 +投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。`Agent` 契约仍是接口,因此其他实现和对象字面量形式的测试替身只需实现同一个最小结构接口。goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。空闲注入会保持待处理,不打开轮次也不运行模型;后续会唤醒的投递在 pre-step 将其放入进入步骤的批次时,它才成为 `user/message`。 -`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可 dequeue 的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算完全停稳。每一次 FIFO 退出都恰好发布一个生命周期事件,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理项的表示方式,使公开生命周期事件保持可关联,既无需维护第二个 steering 包装层,也避免其持久数据发生分歧。后续的[可寻址队列操作](../feature/2026-07-29-addressable-queue-operations.md)决策在该单次入队标识上增加了实时变更,但不改变单消息单轮次或持久消息契约。 +`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可领取的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算完全停稳。每次插入与退出都会发布对应的实时通知,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理消息的表示方式,使持久 splice 与实时事件保持可关联,既无需维护第二个 steering 包装层,也避免数据发生分歧。后续的[已领取 pre-step inbox 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md)决策保留通过 `MessageId` 寻址的实时队列变更,并把单消息生命周期通知与持久的整体队列 splice 投影分离。 ## 相关 diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml index f654a8d4e1..6469b803d4 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md -2026-07-24-separate-context-injection-from-turn-execution.md: 83eb542cb78bf38042d79015153f55622fe46d43 -2026-07-24-separate-context-injection-from-turn-execution.zh.md: cd748e5cf9a9019427f862b3127d36256fc4e4f4 +2026-07-24-separate-context-injection-from-turn-execution.md: ef85a33c7479b59a079c42cd42711a650ebc904e +2026-07-24-separate-context-injection-from-turn-execution.zh.md: ebcf5af6defdc56085bfd647f5134414cd22519b diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md index 83eb542cb7..ef85a33c74 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md @@ -20,27 +20,27 @@ Idle `inject()` exposed a second mismatch. Injection did not request model execu A caller that owns context delivers an identified, frozen `UserMessage` through `inject()` and submits the direct message independently with `followup()` or `steer()`. -Prompt and tool extension points still return `additionalContexts`. These values are outputs of the extension point, not attachments captured from a caller's inbox item. Prompt admission runs before `run()` opens a turn. An allowed prompt and its returned additional contexts enter the new turn as separate messages; a blocked prompt writes neither and opens no turn. Tool-produced additional contexts enter the outbox after the corresponding tool results. +An entering pre-step returns the complete `PreStepDecision.messages` batch for the request being finalized. Tool extension points still return `additionalContexts`, which enter the next-step inbox only after the corresponding tool results. These values are extension-point outputs, not attachments captured from a caller's inbox item. Every additional context is an independent `user/message` whose `source` records provenance. There is no `context/message`, prompt-prefix placement, stable request delimiter, or prompt envelope. Transcript and UI consumers distinguish direct user messages from injected context by `source`. ## Injection lifecycle -During prompt admission or an open turn, `inject()` stages context in the loop outbox. The private next-step acceptance window opens before `agent/prompt-submit` and closes before `turn/end`, so steering and context accepted for one boundary reach the same following request while a `turn/end` listener's late steering becomes a queued prompt. The loop drains the outbox at a safe step boundary, preserving tool protocol adjacency: context accepted during an assistant tool-call batch appears only after that batch's complete ordered results. +`inject()` always inserts context into the non-waking `next-step` inbox. A collecting or running driver claims it at the nearest later pre-step boundary. An idle driver leaves it pending until `followup()` or `steer()` supplies waking work; cancellation or disposal may discard it first. -Outside that window, `inject()` appends its `user/message` immediately. It does not increment turn numbering, emit `turn/start` or `turn/end`, change agent status, or run the model; persistence observes the append through `session/event`. +The loop claims the current next-step batch before running `agent/pre-step`, so an injection that arrives after that claim may miss the request already being finalized. The next boundary claims it instead. An enter decision appends its returned messages inside the owning turn before the request consumes them. Context produced during an assistant tool-call batch therefore appears after that batch's complete ordered results. -If prompt admission blocks or fails, a caller-staged context-only batch appends immediately without a turn. Steering and context staged beside it remain in the outbox for a later admitted prompt; cancellation or disposal may discard them. Hook-produced `additionalContexts` never materialize because they belong to the rejected admission decision. +If pre-step rejects or throws, its claimed injected context, steering, and queued prompt stay removed and no returned batch is appended. Messages inserted after that atomic claim are unaffected and remain pending. -The session invariant permits `user/message` between turns while continuing to require turn enclosure for core execution events, steering, assistant output, and tools. Merge-extensible event relations belong to their declaring plugin rather than a core default. Persistence, recovery, resume, fork, and compaction treat valid between-turn events as committed session history rather than an interrupted or discardable turn tail. +The loop appends injected `user/message` events only from entered batches inside a turn. Core execution events, steering, assistant output, and tools remain turn-enclosed; merge-extensible event relations belong to their declaring plugin rather than a core default. ## Extension and caller semantics -`PromptDecision.content` continues to replace only the direct prompt. `PromptDecision.additionalContexts` and tool-result `additionalContexts` retain FIFO order and individual provenance, but no longer select placement. A waterfall listener that delegates with `next()` must preserve downstream prompt content and additional contexts unless it intentionally returns replacements. +The enter branch's `PreStepDecision.messages` is the complete batch for the proposed step. A waterfall listener that delegates with `next()` preserves downstream messages unless it intentionally replaces them; additions follow natural waterfall return order. Tool-result `additionalContexts` retain FIFO order and individual provenance. -Caller-driven injection and hook-produced additional context deliberately have different admission ownership. A hook's additional contexts materialize only after that hook allows the prompt or tool result. Outside a next-step acceptance window, a caller that invokes `inject(context)` and then `followup(prompt)` commits context independently; callers requiring all-or-nothing behavior use a domain-specific admission wrapper. +Caller-driven injection and current-step context deliberately use different timing. `inject()` joins the next pre-step available and cannot promise that a request already being finalized will consume it. A listener that must affect that exact request returns the context in `PreStepDecision.messages`; downstream rejection or failure then prevents it from materializing. -Cross-session references use that domain composition: TUI prepares the snapshot, then either adds it to the prompt's admission decision outside an acceptance window or injects it beside steering during one. The target log contains two simple messages, so later source mutation cannot change replay and transcript consumers do not need a prompt envelope. This supersedes the attachment mechanism in the [cross-session reference decision](../feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules. +Cross-session references use that domain composition: TUI prepares the snapshot, returns it from the idle direct message's pre-step beside that message, or injects it before waking steering during a running turn. The target log contains two simple messages, so later source mutation cannot change replay and transcript consumers do not need a prompt envelope. This supersedes the attachment mechanism in the [cross-session reference decision](../feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules. This decision preserves the caller-owned framing decision from [unwrapped injected content](../simplification/2026-07-20-unwrap-injected-content-envelopes.md) and the one-item turn rule from [one send, one turn](../simplification/2026-07-17-one-send-one-turn.md). The later [standalone log-only event decision](../simplification/2026-07-28-remove-synthetic-log-only-turns.md) applies the same execution-only meaning to plugin-owned records. @@ -48,27 +48,27 @@ This decision preserves the caller-owned framing decision from [unwrapped inject **Keep `SendOptions.contexts` as an atomic attachment.** This preserves all-or-nothing delivery when prompt admission blocks, but it keeps context inside inbox lifecycle state and requires every queue transition and observation event to carry it. The generic agent API should not encode a domain transaction that most callers can express as context injection followed by message delivery. -**Keep a distinct `context/message` session event.** A separate event makes the out-of-turn exception narrower, but user-role model input would again have two event types with identical projection. `user/message.source` already carries the distinction needed by policy, transcript, and replay consumers. +**Keep a distinct `context/message` session event.** User-role model input would again have two event types with identical projection. `user/message.source` already carries the distinction needed by policy, transcript, and replay consumers. -**Keep one-shot turns for idle injection.** This retains universal turn enclosure and a convenient flush boundary, but it makes turn counts and turn observers report work that never ran the model. Durability is an independent session concern and can be awaited without fabricating execution. +**Keep one-shot turns for idle injection.** This gives idle context an immediate durability boundary, but it makes turn counts and turn observers report work that never ran the model. Non-waking context instead remains pending until real waking work supplies a request. **Keep `prompt-prefix` as an optional placement.** Prefix baking can make the context and request appear in one provider message, but it introduces a second representation of the direct prompt and spreads placement handling across admission, steering, logging, replay, and UI code. Producers that require textual framing may include it in their own context content. -**Let hooks call `inject()` directly instead of returning additional contexts.** Direct injection would erase the extension point's admission ownership: a listener could append context before a downstream listener blocks the operation. Returning `additionalContexts` keeps the waterfall result authoritative while sharing the same post-admission outbox path. +**Let prompt hooks call `inject()` instead of returning messages.** An injection may miss the request whose prompt is already being finalized and would escape a downstream block of that decision. Returning the complete message batch keeps current-request context under the waterfall's authority. ## Verification -- Delivery inputs and steering inbox records contain no attached contexts; `agent/inbox/enqueue` reports only the message plus its resolved queued-or-steering placement. +- Delivery inputs and steering inbox records contain no attached contexts; `agent/inbox/inserted` reports only the inserted message, while the durable splice retains its target list. - `UserMessage` is the shared identified, frozen shape across prompt interception, tool execution, hook bridges, guards, and context producers. - Prompt-prefix placement, prompt envelopes, and `context/message` are absent from public types, durable events, projection, and UI replay. -- Idle `inject()` appends one sourced `user/message` without a turn or model call. -- Admission-time and active-turn injection drain at safe boundaries after complete tool-result batches and before the request that consumes them. -- Blocked prompt admission opens no turn and appends neither the prompt nor hook-produced additional contexts; caller context alone falls back to an idle append, while a steering boundary remains available to retry. -- Unit, persistence/resume, invariant, host/client queue, and TUI coverage pin event order, admission ownership, and reconnect classification. +- Idle `inject()` queues one non-waking next-step item and appends nothing until a later waking delivery starts pre-step processing. +- Collecting and active-turn injection is claimed at the nearest later pre-step boundary, after complete tool-result batches and before the request that consumes it. +- Rejected or failed pre-step drops its claimed batch; input inserted after the claim remains pending. +- Unit, persistence/resume, invariant, and TUI coverage pin event order, claim ownership, and durable replay. ## Consequences -- One surface event is valid outside turns, so persistence scanning, crash repair, forking, compaction, and session queries distinguish execution enclosure from session history. +- Idle injection is not model-visible until a later pre-step enters it and may be lost to cancellation or disposal. - Consecutive user-role messages replace one baked prompt message; provider adapters preserve that ordering. -- Outside an acceptance window, `inject()` followed by a blocked `followup()` leaves context without its intended direct prompt unless the caller supplies domain-specific admission ownership. +- Exact-current-request context must be returned from `agent/pre-step`; ordinary injection provides only nearest-later-boundary delivery. - The public delivery contract and inbox records remain small: no context attachment, context-placement metadata, prompt envelope, or duplicate durable event type. diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md index cd748e5cf9..ebcf5af6de 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md @@ -20,27 +20,27 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入: 拥有上下文的调用方通过 `inject()` 交付带标识且冻结的 `UserMessage`,再独立使用 `followup()` 或 `steer()` 提交直接消息。 -提示词和工具扩展点仍可返回 `additionalContexts`。这些值是扩展点的输出,而不是从调用方收件箱条目捕获的附件。提示词准入在 `run()` 打开轮次之前执行。获准的提示词及其返回的额外上下文会作为独立消息进入新轮次;提示词被阻止时,两者都不写入,也不打开轮次。工具产生的额外上下文则在对应工具结果之后进入 outbox。 +返回 enter 的 pre-step 会为正在最终确定的请求返回完整的 `PreStepDecision.messages` 批次。工具扩展点仍可返回 `additionalContexts`,这些上下文只会在对应工具结果之后进入 next-step inbox。这些值是扩展点的输出,而不是从调用方 inbox 条目捕获的附件。 每项额外上下文都是独立的 `user/message`,并由 `source` 记录来源。不再有 `context/message`、prompt-prefix 放置方式、稳定请求分隔符或提示词封套。transcript 与 UI 消费方通过 `source` 区分直接用户消息和注入上下文。 ## 注入生命周期 -提示词准入期间或轮次打开时,`inject()` 会将上下文暂存在 loop outbox 中。私有的 next-step 接受窗口在 `agent/prompt-submit` 前打开,并在 `turn/end` 前关闭,因此同一边界接受的 steering 和上下文会进入后续同一次请求,而 `turn/end` 监听器提交的晚到 steering 则成为排队提示词。agent loop 会在安全的步骤边界排空 outbox,同时保持工具协议要求的相邻关系:在助手工具调用批次期间接受的上下文,只能出现在该批次所有有序结果之后。 +`inject()` 始终把上下文插入不会唤醒的 `next-step` inbox。collecting 或 running 驱动器会在最近的后续 pre-step 边界领取它。idle 驱动器会让它保持待处理,直至 `followup()` 或 `steer()` 提供可唤醒工作;在此之前,取消或 dispose(资源释放)可能将其丢弃。 -在该窗口之外,`inject()` 会立即追加对应的 `user/message`。它不会增加轮次编号、发出 `turn/start` 或 `turn/end`、改变 agent 状态,也不会运行模型;持久化通过 `session/event` 观察这次追加。 +循环会先领取当前 next-step 批次,再运行 `agent/pre-step`,因此领取后到达的注入可能赶不上正在最终确定的请求,而由下一次边界领取。enter decision 返回的消息会在所属轮次内、消费它们的请求之前追加。在助手工具调用批次期间产生的上下文因此只会出现在该批次全部有序结果之后。 -如果提示词准入被阻止或失败,调用方暂存的仅含上下文的批次会立即追加,且不产生轮次。steering 及与其一同暂存的上下文会留在 outbox 中,供后续获准提示词使用;取消或 dispose(资源释放)可能丢弃它们。钩子产生的 `additionalContexts` 属于被拒绝的准入决策,因此永远不会落入日志。 +如果 pre-step reject 或抛错,其已领取的注入上下文、steering 与排队提示词都会保持已删除,也不会追加返回批次。原子领取后插入的消息不受影响,继续保持待处理。 -会话不变量允许 `user/message` 位于两个轮次之间,同时继续要求核心执行事件、steering、助手输出和工具事件均受轮次边界约束。可合并扩展事件的关系由声明它们的插件拥有,而不是采用核心默认规则。持久化、恢复、resume、fork 和压缩会把合法的轮次间事件当作已提交会话历史,而不是中断轮次或可丢弃的日志尾部。 +loop 只会在轮次内从进入步骤的批次追加注入的 `user/message`。核心执行事件、steering、助手输出和工具事件仍受轮次边界约束;可合并扩展事件的关系由声明它们的插件拥有,而不是采用核心默认规则。 ## 扩展点与调用方语义 -`PromptDecision.content` 仍只替换直接提示词。`PromptDecision.additionalContexts` 和工具结果的 `additionalContexts` 保留 FIFO 顺序及各自来源,但不再选择放置方式。waterfall(瀑布式事件)监听器调用 `next()` 委托时,必须保留下游返回的提示词内容和额外上下文,除非它有意返回替代值。 +enter 分支的 `PreStepDecision.messages` 是拟议步骤的完整批次。waterfall(瀑布式事件)监听器调用 `next()` 委托时,会保留下游消息,除非有意替换;新增消息遵循 waterfall 的自然返回顺序。工具结果的 `additionalContexts` 保留 FIFO 顺序及各自来源。 -调用方主动注入与钩子产生的额外上下文具有不同的准入归属。钩子的额外上下文只会在该钩子允许提示词或工具结果后落入日志。在 next-step 接受窗口之外,调用方执行 `inject(context)` 后再执行 `followup(prompt)` 时,会独立提交上下文;需要全有或全无语义的调用方应使用领域专用的准入包装层。 +调用方主动注入与当前步骤上下文刻意采用不同的时序。`inject()` 会加入下一个可用 pre-step,无法保证正在最终确定的请求会消费它。必须影响该请求的监听器在 `PreStepDecision.messages` 中返回上下文;下游 reject 或失败时,该上下文不会落入日志。 -跨会话引用采用这种领域组合方式:TUI 先准备快照,然后在接受窗口之外将其加入提示词准入决策,或在窗口期间将其注入到 steering 旁。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本决策取代[跨会话引用决策](../feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。 +跨会话引用采用这种领域组合方式:TUI 先准备快照,然后在 idle 直接消息的 pre-step 中把快照与该消息一同返回,或在 running 轮次中先注入快照再唤醒 steering。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本决策取代[跨会话引用决策](../feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。 本决策保留[移除注入内容封套](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的调用方自主管理框架原则,以及[一次 send、一个轮次](../simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则。后续的[独立纯日志事件决策](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)将同样的「轮次仅表示执行」语义应用于插件所属记录。 @@ -48,27 +48,27 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入: **保留 `SendOptions.contexts` 作为原子附件。** 提示词准入阻止消息时,这种方式能保留全有或全无交付,但也会让上下文继续成为收件箱生命周期状态的一部分,并迫使每次队列转换和观察事件携带它。大多数调用方都可以通过先注入上下文、再交付消息来表达需求,通用 agent API 不应内置领域事务。 -**保留独立的 `context/message` 会话事件。** 独立事件可以缩小轮次外事件的例外范围,但面向模型的 user-role 输入会再次拥有两个投影完全相同的事件类型。`user/message.source` 已能为策略、transcript 和回放消费方提供所需区分。 +**保留独立的 `context/message` 会话事件。** 面向模型的 user-role 输入会再次拥有两个投影完全相同的事件类型。`user/message.source` 已能为策略、transcript 和回放消费方提供所需区分。 -**为空闲注入保留一次性轮次。** 这种方式能保留通用轮次封闭和方便的刷新边界,却会让轮次计数与轮次观察方报告从未运行模型的工作。持久性是独立的会话关注点,无需伪造执行即可等待。 +**为空闲注入保留一次性轮次。** 这种方式会为空闲上下文提供即时持久边界,却让轮次计数与轮次观察方报告从未运行模型的工作。不会唤醒的上下文改为保持待处理,直至真实的可唤醒工作提供请求。 **保留 `prompt-prefix` 可选放置方式。** 前缀烘焙可以让上下文和请求位于同一条提供方消息中,但它会引入直接提示词的第二种表示,并把放置处理扩散到准入、steering、日志、回放和 UI 代码。需要文本框架的生产方可以直接把它写入自身上下文内容。 -**让钩子直接调用 `inject()`,而不是返回额外上下文。** 直接注入会破坏扩展点的准入归属:下游监听器阻止操作之前,上游监听器就可能已经追加上下文。返回 `additionalContexts` 能维持 waterfall 结果的最终权威性,同时复用准入后的 outbox 路径。 +**让提示词钩子调用 `inject()`,而不是返回消息。** 注入可能赶不上提示词正在最终确定的请求,也会逃逸下游对该 decision 的阻止。返回完整消息批次能让当前请求上下文继续受 waterfall 约束。 ## 验证 -- 投递输入与 steering 收件箱记录不包含附加上下文;`agent/inbox/enqueue` 只报告消息及其已解析的 queued 或 steering 放置方式。 +- 投递输入与 steering inbox 记录不包含附加上下文;`agent/inbox/inserted` 只报告插入消息,目标列表由持久 splice 保留。 - `UserMessage` 是提示词拦截、工具执行、hook bridge、guard 和上下文生产方共享的带标识且冻结的形状。 - 公共类型、持久事件、投影和 UI 回放中均不存在 prompt-prefix 放置方式、提示词封套与 `context/message`。 -- 空闲 `inject()` 在不产生轮次或模型调用的情况下,追加一条带来源的 `user/message`。 -- 准入期间和活跃轮次中的注入会在完整工具结果批次之后的安全边界排空,并在消费它们的请求之前进入日志。 -- 被阻止的提示词准入不会打开轮次,也不会追加提示词或钩子产生的额外上下文;仅有调用方上下文时会回退为空闲追加,而带 steering 的边界仍可重试。 -- 单元测试、持久化与 resume 测试、不变量测试、宿主/客户端队列测试和 TUI 覆盖会固定事件顺序、准入归属和重连分类。 +- idle 状态下的 `inject()` 会将一条不会唤醒的 next-step 消息排队;后续可唤醒投递开始 pre-step 处理前,日志不会追加任何内容。 +- collecting 和活跃轮次中的注入会在最近的后续 pre-step 边界领取,并位于完整工具结果批次之后、消费它的请求之前。 +- pre-step reject 或失败会丢弃其已领取批次;领取后插入的 inbox 工作继续保持待处理。 +- 单元测试、持久化与 resume 测试、不变量测试和 TUI 覆盖会固定事件顺序、领取归属和持久回放。 ## 后果 -- 一个表层事件可以合法位于轮次之外,因此持久化扫描、崩溃恢复、fork、压缩和会话查询需要区分执行封闭与会话历史。 +- idle 注入要到后续 pre-step 让它进入步骤后才会对模型可见,并可能因取消或 dispose 而丢失。 - 两条连续的 user-role 消息会取代一条烘焙后的提示词消息;提供方适配器会保留这一顺序。 -- 在接受窗口之外,`inject()` 后跟一个被阻止的 `followup()` 会留下缺少预期直接提示词的上下文,除非调用方提供领域专用的准入归属。 +- 必须影响当前请求的上下文要从 `agent/pre-step` 返回;普通注入只保证由最近的后续边界交付。 - 公共投递契约和收件箱记录保持精简:没有上下文附件、上下文放置元数据、提示词封套或重复的持久事件类型。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 6b8c6de32d..51555e5dea 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: d19b256b834110d3cbb540cc0e039e61c693e98c -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1f88dd2065eaba7282ae3ed9e82d6872fdfb8497 +2026-07-25-web-client-session-scope-and-provide-channel.md: f133fe15df8a75e8f43802282cca5ea1f50db3cc +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 0751295da8f6f4af77e061a7551087cb43452cb6 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index d19b256b83..f133fe15df 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-25-web-client-session-scope-and-provide-channel.zh.md) -> Scope: the client Agent scope (actx) and targeted events, the client/host materialization parity model, the blank-session bit and reuse (`connectWorkspace`), the per-session provisioning channel (`sessions.provide`), the read-only queue mirror (`session/queued`), and the host wire smalls that carry these capabilities (the summary `blank` column, the `host/session-added` frame field, and the `host/commands-changed` frame). The input state machine and the slash pipeline live in the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md); the command business surfaces live in the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md). +> Scope: the client Agent scope (actx) and targeted events, the client/host materialization parity model, the blank-session bit and reuse (`connectWorkspace`), the per-session provisioning channel (`sessions.provide`), and the host wire smalls that carry these capabilities (the summary `blank` column, the `host/session-added` frame field, and the `host/commands-changed` frame). The input state machine and the slash pipeline live in the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md); the command business surfaces live in the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md). ## Problem @@ -101,7 +101,6 @@ Slot scope is the closed set `root | session-maybe | session`: ### The read-only queue mirror -- The MuxFrame `session/queued`: the Session holds a read-only inbox mirror (previews truncated; steering retired by source match). The host stamps the agent-loop's acceptance-time steering classification on live and replayed frames, so a reconnect baseline does not depend on replaying an earlier `turn/start`. Queue frames never enter history — pure stream state, cleared on reconnect and refilled from the new baseline; the never-instantiated window is buffered and replayed through the manager pendingBuffers. - Queue semantics: running does not lock input; ordinary messages queue through `session.prompt {mode:'queue'}`, and commands never queue. ### Host wire smalls diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 1f88dd2065..0751295da8 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-25-web-client-session-scope-and-provide-channel.md) | 中文 -> 范围:client Agent scope(actx)与定向事件、client/host 实体化对等模型、空会话 blank 位与复用(`connectWorkspace`)、per-session 供数通道(`sessions.provide`)、队列只读镜像(`session/queued`),以及承载这些能力的 host wire 小件(summary `blank` 列、`host/session-added` 帧字段、`host/commands-changed` 帧)。输入状态机与 slash 管线见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md);命令业务面见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.md)。 +> 范围:client Agent scope(actx)与定向事件、client/host 实体化对等模型、空会话 blank 位与复用(`connectWorkspace`)、per-session 供数通道(`sessions.provide`),以及承载这些能力的 host wire 小件(summary `blank` 列、`host/session-added` 帧字段、`host/commands-changed` 帧)。输入状态机与 slash 管线见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md);命令业务面见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.md)。 ## 问题 @@ -101,7 +101,6 @@ slot scope 是闭集 `root | session-maybe | session`: ### 队列只读镜像 -- MuxFrame `session/queued`:Session 持只读 inbox 镜像(预览截断、steering 按 source 匹配退休)。宿主会在实时和回放帧中标记 agent loop 接受消息时的 steering 分类,因此重连基线不依赖回放更早的 `turn/start`。queue 帧不进 history,纯 stream 态——重连清空、新基线重灌;未实例化窗口经 manager pendingBuffers 缓冲重放。 - 队列语义:running 不锁输入;普通消息经 `session.prompt {mode:'queue'}` 排队,命令永不排队。 ### host wire 小件 diff --git a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.i18n.yaml index 85ccc38306..21e4af70dc 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md -2026-07-28-identified-immutable-message-values.md: 66c11cfddae2ce122e248032af6b0349dde8995e -2026-07-28-identified-immutable-message-values.zh.md: c0ed3bd87b1dfc411896868a2e0f8014a6af0a22 +2026-07-28-identified-immutable-message-values.md: 89afc7cbd9b19d378f62529e353f21d47fb3e3cf +2026-07-28-identified-immutable-message-values.zh.md: 39816b4968b5e1b8ed98a1d0b665cd5d418fa8ac diff --git a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md index 66c11cfdda..89afc7cbd9 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md +++ b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md @@ -12,13 +12,13 @@ This made identity a routing side effect rather than a message invariant. Produc ## Decision -`@deepseek-ai/dsh-llm` owns one `Message` value with required `id`, `role`, `content`, and `source`. `MessageId` is opaque and shared by user, assistant, and tool-result messages. A message receives its id at creation, before routing, prompt admission, durable append, or request projection. The same id survives every representation boundary. +`@deepseek-ai/dsh-llm` owns one `Message` value with required `id`, `role`, `content`, and `source`. `MessageId` is opaque and shared by user, assistant, and tool-result messages. A message receives its id at creation, before inbox routing, claim, pre-step rewriting, durable append, or request projection. The same id survives every representation boundary. `createMessage(input)` is the canonical role-generic creation boundary. It mints a `MessageId`, detaches the supplied role, content, and source, and deep-freezes the complete value before returning it. `createUserMessage({ content, source })` fixes the user role for prompt and context producers. `createAssistantMessage({ content, source })` fixes both the assistant role and the model source kind, so model-output producers supply only content and model provenance. All creation helpers exclude an input id so callers cannot accidentally present creation as import. `freezeMessage(message)` is the separate import or transformation boundary: it detaches and deep-freezes a message whose identity already exists, without minting a replacement. The helpers live in `dsh-llm` beside the base message vocabulary because their complete contracts depend only on that vocabulary. `createToolResultMessage()` belongs with the other creation helpers: it couples a tool call id to the exact user-role tool-result block and source without depending on session state or events. `dsh-session` consumes complete messages rather than owning their construction. -The `Agent` interface accepts a complete `UserMessage` through `followup`, `steer`, and `inject`. These operations never allocate or return identity; they freeze an imported value whose id the caller already holds. Prompt admission receives that message directly. A content rewrite creates a frozen replacement with the same id, while an additional context is a separately created `UserMessage` with its own id. +The `Agent` interface accepts a complete `UserMessage` through `followup`, `steer`, and `inject`. These operations never allocate or return identity; they freeze an imported value whose id the caller already holds. Inbox claims and `agent/pre-step` receive that message directly. A content rewrite creates a frozen replacement with the same id, while an additional context is a separately created `UserMessage` with its own id. Durable message-producing events store complete messages. `user/message` stores its `UserMessage` directly; `assistant/message`, `tool/result`, and `steering/message` wrap their role-specialized message beside event-local position, usage, failure, or presentation facts. Session derivation returns those frozen values instead of reconstructing anonymous messages. Assistant assembly creates a model-sourced message when a response completes, and tool execution creates a tool-sourced message when a result is committed. @@ -38,7 +38,7 @@ Any operation that changes only the representation of an existing semantic messa Every message producer must choose creation or import explicitly, and tests construct complete values rather than partial content/source records. UUID generation moves outward to the first semantic creation point, so deterministic fixtures that provide an existing id use `freezeMessage()` instead of `createMessage()`. -Live inbox events, durable events, derived history, and model requests can correlate one message without content equality or envelope-specific ids. Prompt admission and UI attachment cleanup can compare `MessageId` before a turn exists. Deep freezing prevents a producer, hook, or observer from changing the value after identity is established. +Live inbox events, durable events, derived history, and model requests can correlate one message without content equality or envelope-specific ids. Claim policy and UI attachment cleanup can compare `MessageId` before a turn exists. Deep freezing prevents a producer, hook, or observer from changing the value after identity is established. The shared representation removes the old `UserMessageData`/`AgentMessage` split and folds provider provenance into typed message sources. Event envelopes still own facts that are not message semantics, such as turn and step position, token usage, internal tool failure identity, and presentation metadata. diff --git a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md index c0ed3bd87b..39816b4968 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md @@ -12,13 +12,13 @@ harness 曾存在多种形似消息的表示,各自采用不同的标识规则 ## 决策 -`@deepseek-ai/dsh-llm` 持有唯一一种 `Message` 值,其 `id`、`role`、`content` 和 `source` 均为必填。`MessageId` 是不透明标识,由用户消息、assistant 消息和工具结果消息共享。消息在创建时就会获得 id,早于路由、提示词准入、持久追加或请求投影。同一个 id 会跨越每个表示边界。 +`@deepseek-ai/dsh-llm` 持有唯一一种 `Message` 值,其 `id`、`role`、`content` 和 `source` 均为必填。`MessageId` 是不透明标识,由用户消息、assistant 消息和工具结果消息共享。消息在创建时就会获得 id,早于 inbox 路由、领取、pre-step 改写、持久追加或请求投影。同一个 id 会跨越每个表示边界。 `createMessage(input)` 是角色通用的规范创建边界。它会生成 `MessageId`,将输入的角色、内容和来源与输入分离,并在返回完整值前将其深度冻结。`createUserMessage({ content, source })` 为提示词和上下文生产方固定 user 角色。`createAssistantMessage({ content, source })` 同时固定 assistant 角色与模型来源类别,因此模型输出生产方只需提供内容和模型溯源信息。所有创建辅助函数的输入都不包含 id,因此调用方不会意外地把创建表达为导入。`freezeMessage(message)` 是独立的导入或转换边界:它会将已有标识的消息与输入分离并深度冻结,不会生成替代标识。 这些辅助函数位于基础消息词汇旁的 `dsh-llm` 中,因为它们的完整契约只依赖该词汇。`createToolResultMessage()` 与其他创建辅助函数同属此处:它使用同一个工具调用 id,将工具来源与确切的 user-role 工具结果块耦合起来,不依赖会话状态或事件。`dsh-session` 只消费完整消息,不负责构造它们。 -`Agent` 接口通过 `followup`、`steer` 和 `inject` 接收完整的 `UserMessage`。这些操作绝不会分配或返回标识;它们会冻结导入的值,而调用方已经持有该值的 id。提示词准入会直接接收该消息。改写内容时会创建具有相同 id 的冻结替代值,而每个附加上下文都是单独创建的 `UserMessage`,拥有自己的 id。 +`Agent` 接口通过 `followup`、`steer` 和 `inject` 接收完整的 `UserMessage`。这些操作绝不会分配或返回标识;它们会冻结导入的值,而调用方已经持有该值的 id。inbox 领取和 `agent/pre-step` 会直接接收该消息。改写内容时会创建具有相同 id 的冻结替代值,而每个附加上下文都是单独创建的 `UserMessage`,拥有自己的 id。 产生持久消息的事件会存储完整消息。`user/message` 直接存储其 `UserMessage`;`assistant/message`、`tool/result` 和 `steering/message` 则将各自角色专用的消息与事件本地的位置、用量、失败或呈现事实包装在一起。会话派生会返回这些冻结值,而不是重建匿名消息。assistant 组装会在响应完成时创建模型来源的消息,工具执行会在提交结果时创建工具来源的消息。 @@ -38,7 +38,7 @@ harness 曾存在多种形似消息的表示,各自采用不同的标识规则 每个消息生产方都必须显式选择创建或导入,测试也会构造完整值,而不是不完整的内容/来源记录。UUID 的生成会前移至最初的语义创建点,因此提供已有 id 的确定性 fixture 会使用 `freezeMessage()`,而不是 `createMessage()`。 -实时 inbox 事件、持久事件、派生历史和模型请求可以关联同一条消息,无需比较内容或使用封装专用 id。提示词准入和 UI 附件清理可以在轮次存在之前比较 `MessageId`。深度冻结可以防止生产方、钩子或观察方在标识建立后更改消息值。 +实时 inbox 事件、持久事件、派生历史和模型请求可以关联同一条消息,无需比较内容或使用封装专用 id。领取策略和 UI 附件清理可以在轮次存在之前比较 `MessageId`。深度冻结可以防止生产方、钩子或观察方在标识建立后更改消息值。 共享表示移除了旧的 `UserMessageData`/`AgentMessage` 划分,并将提供方溯源信息纳入带类型的消息来源。事件封装仍持有不属于消息语义的事实,例如轮次与步骤位置、token 用量、内部工具失败标识和呈现元数据。 diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml new file mode 100644 index 0000000000..841ef3052c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md +2026-07-31-claimed-pre-step-inbox-lifecycle.md: a44f0cb906c9dd7a98b20a2f34fce902ac68a6d2 +2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 32fc435d3acef4d3cbd5edd57961cb098c1a1637 diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md new file mode 100644 index 0000000000..a44f0cb906 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md @@ -0,0 +1,41 @@ +# Agent Note: Claim inbox input before one pre-step decision + +Status: implemented + +English | [中文](2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md) + +## Problem + +The loop previously split one step boundary across prompt preparation, prompt admission, and a serial step hook. Claimed input could be retained or discarded by an admission result, and live queue events carried shapes that duplicated durable inbox state. Plugins had to choose whether to mutate the inbox, rewrite a submitted batch, or append directly to session history, while observers could not rely on one exact ordering. + +Occurrence-local inbox wrappers also duplicated the identity already carried by every `UserMessage`. They made insertion, editing, claiming, cancellation, reconnect projection, and step entry one combined protocol even though the append-only session already owned the durable queue projection. + +## Decision + +Before every proposed step, `Inbox.claim(target)` atomically removes the complete batch: all `next-step` messages and, at a turn boundary, one `next-turn` message. Claiming records normalized `agent/inbox/spliced` pure deletions with no outcome. The loop then emits `agent/inbox/claimed { message, turn }` once per claimed message and awaits the single `agent/pre-step` waterfall with that exclusive batch and `{ turn, step, signal }`. + +`PreStepDecision` is `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`. Reject opens no turn or step and leaves the claimed batch removed. Enter supplies the complete batch appended as `user/message` events after `step/start`. A listener wrapping `next()` preserves downstream changes unless it intentionally replaces them, so all message rewrites settle once in the final return value. There is no `agent/prompt-prepare`, `agent/prompt-submit`, or `agent/step` seam. + +The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, `update`, `remove`, and `splice` commit normalized splices. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. These live events add no placement, outcome, or batch fields. + +The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Whole-queue consumers, including the Web queue projection and reconnect baseline, use the durable `agent/inbox/spliced` stream; UI edits and removals route through `Inbox.splice()` or another Inbox mutation method so the same projection records every change. + +Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses that weaker timing deliberately: it prepends its current sourced context to `next-step`, replaces an exact still-pending predecessor, and does not alter the current pre-step decision. + +The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` now owns addressability, while the retained Host queue mirror derives its snapshots from the durable splice projection. + +## Alternatives considered + +**Keep separate prepare and admit hooks.** This lets preparation mutate the inbox before claiming and admission rewrite afterward, but it creates two ordering surfaces for one boundary and makes cancellation ownership ambiguous. + +**Let rejection requeue the claimed batch.** This preserves retry-like behavior but turns a veto into hidden queue mutation, duplicates later work unless every race is fenced, and prevents claim from being an atomic ownership transfer. + +**Put placement and outcome on every live event.** Durable splices already own those facts. Repeating them on live notifications creates a second contract that can drift and is unnecessary for consumers holding the exact message identity. + +## Verification + +Agent-loop coverage pins claim-before-pre-step ordering, exact live event payloads, rejection before any turn, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, workspace-context delayed insertion and replacement, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, and resumed durable projection. Generated event and type catalogs expose only the new seam and payloads. + +## Consequences + +The loop has one awaited decision before each step and one ownership transfer for its input. Claimed messages never return to the inbox implicitly; later insertions remain independent. Live events are symmetrical with other inbox notifications without mirroring durable metadata, and plugins can choose exact-current-step rewriting or ordinary later inbox delivery explicitly. diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md new file mode 100644 index 0000000000..32fc435d3a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md @@ -0,0 +1,41 @@ +# Agent Note:在单一 pre-step 决策前领取 inbox 输入 + +Status: implemented + +[English](2026-07-31-claimed-pre-step-inbox-lifecycle.md) | 中文 + +## 问题 + +循环此前把一个步骤边界拆成提示词准备、提示词准入与串行 step hook。准入结果可以保留或丢弃已领取输入,实时队列事件还携带了与持久 inbox 状态重复的形状。插件不得不在修改 inbox、改写已提交批次与直接追加会话历史之间选择,而观察方无法依赖一套明确顺序。 + +单次出现专属的 inbox wrapper 也重复了每个 `UserMessage` 已有的标识。它把插入、编辑、领取、取消、重连投影与步骤进入合并成一套协议,但仅追加会话本就拥有持久队列投影。 + +## 决策 + +每个拟议步骤之前,`Inbox.claim(target)` 会原子移除完整批次:全部 `next-step` 消息,以及轮次边界上的一条 `next-turn` 消息。领取会记录规范化、不带 outcome 的纯删除 `agent/inbox/spliced`。随后,循环针对每条已领取消息发出一次 `agent/inbox/claimed { message, turn }`,并用该独占批次与 `{ turn, step, signal }` 等待唯一的 `agent/pre-step` waterfall(瀑布式事件)。 + +`PreStepDecision` 为 `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`。reject 不会打开轮次或步骤,并让已领取批次保持已删除。enter 提供在 `step/start` 后以 `user/message` 追加的完整批次。包装 `next()` 的监听器会保留下游变更,除非有意替换,因此全部消息改写只在最终返回值中一次性结算。系统不再存在 `agent/prompt-prepare`、`agent/prompt-submit` 或 `agent/step` seam。 + +持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend`、`update`、`remove` 与 `splice` 会提交规范化 splice。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。这些实时事件不增加 placement、outcome 或批次字段。 + +两类事件表面服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。包括 Web 队列投影和重连基线在内的整体队列消费方使用持久 `agent/inbox/spliced` 流;UI 编辑与移除经 `Inbox.splice()` 或其他 Inbox 变更方法进入,从而让同一投影记录所有变化。 + +必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 刻意采用较弱的时序:它把当前带来源上下文 prepend 到 `next-step`,替换仍在等待的确切前序消息,并且不修改当前 pre-step 决策。 + +已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现 wrapper 设计。现在由 `MessageId` 负责寻址,而保留的 Host 队列镜像根据持久 splice 投影派生快照。 + +## 曾考虑的替代方案 + +**保留分离的 prepare 与 admit hook。** 这样准备阶段可以在领取前修改 inbox,准入阶段可以在领取后改写,但同一边界会出现两个顺序表面,取消归属也会变得模糊。 + +**reject 时把已领取批次重新入队。** 这看似保留重试行为,却会让否决隐式修改队列;若不为每个竞态加围栏,还会复制后续工作,并使 claim 无法成为原子所有权转移。 + +**在每个实时事件上携带 placement 与 outcome。** 持久 splice 已经拥有这些事实。实时通知重复它们会建立可能漂移的第二份契约,而持有确切消息标识的消费方并不需要这些字段。 + +## 验证 + +Agent-loop 覆盖固定先领取再 pre-step 的顺序、实时事件的确切载荷、打开任何轮次前的 reject、最终批次改写、领取后插入的输入、监听器失败与取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、workspace-context 的延后插入与替换、plan/goal/hook 行为、UI 清理、压缩、检查点以及恢复后的持久投影。生成的事件与类型目录只公开新的 seam 与载荷。 + +## 后果 + +循环在每个步骤前只有一个需等待的决策,对输入也只有一次所有权转移。已领取消息不会隐式返回 inbox;后续插入保持独立。实时事件与其他 inbox 通知保持对称,但不镜像持久元数据;插件可以显式选择精确的当前步骤改写,或普通的后续 inbox 投递。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml index 0f80e5ff22..979b75376c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md -2026-07-21-semantic-session-checkpoints.md: 927a4c5d6d2aad5dea460ea29f97c1686e9d5398 -2026-07-21-semantic-session-checkpoints.zh.md: 6454b496aa8c03c172d6a4bc969e43e8dbca2430 +2026-07-21-semantic-session-checkpoints.md: 697d878dccbfab1ded9f73e134d594ba6f0665e1 +2026-07-21-semantic-session-checkpoints.zh.md: 48bd97001d04fa4a4f6037ef9d7936278003df26 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md index 927a4c5d6d..697d878dcc 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md @@ -10,9 +10,9 @@ Persistence buffered every synchronous `session/event` until the loop's final tu ## Decision -`dsh-session-checkpoint-policy` owns semantic durability barriers as a zero-config plugin beside a persistence backend. At `agent/step`, it flushes pending prompt input or the preceding response/result batch before the next request is derived. It wraps `llm/stream` lazily and flushes the live session after `request/header` is logged but before the adapter stream is constructed. It wraps top-level `tools/execute` after ordered pre-execute policy and flushes the recorded `tool/call` before the tool body; nested dispatches reuse the outer model-visible call. The loop's final `turn/end` checkpoint remains the closing boundary and settles before another queued turn or idle observation. +`dsh-session-checkpoint-policy` owns semantic durability barriers as a zero-config plugin beside a persistence backend. At `agent/pre-step`, it flushes pending prompt input or the preceding response/result batch before the next request is derived. It wraps `llm/stream` lazily and flushes the live session after `request/header` is logged but before the adapter stream is constructed. It wraps top-level `tools/execute` after ordered pre-execute policy and flushes the recorded `tool/call` before the tool body; nested dispatches reuse the outer model-visible call. The loop's final `turn/end` checkpoint remains the closing boundary and settles before another queued turn or idle observation. -Persistence and checkpoint scheduling remain separate Cordis plugins. A backend makes requested `session/flush` boundaries durable but does not choose them; loading it without this policy is valid and retains the loop's coarser checkpoints. First-party persisted apps and runtimes explicitly mount both, while a specialized deployment may intentionally omit or replace the policy. Registration order governs whether events appended by other `agent/step` listeners precede this checkpoint; prompt input and the preceding loop-owned assistant message and ordered results are already in the log. +Persistence and checkpoint scheduling remain separate Cordis plugins. A backend makes requested `session/flush` boundaries durable but does not choose them; loading it without this policy is valid and retains the loop's coarser checkpoints. First-party persisted apps and runtimes explicitly mount both, while a specialized deployment may intentionally omit or replace the policy. Registration order governs whether events appended by other `agent/pre-step` listeners precede this checkpoint; prompt input and the preceding loop-owned assistant message and ordered results are already in the log. Checkpoint failure and cancellation are fail-closed at effect boundaries. A rejected request checkpoint prevents adapter dispatch; a rejected tool checkpoint becomes an error result without invoking the tool body. If cancellation lands while the tool checkpoint is pending, the policy rechecks the signal and returns the canonical `ABORTED_BEFORE_DISPATCH` result. A rejected between-step checkpoint closes the turn before another model request. A rejected final turn checkpoint is reported live and does not prevent later queued work. Persistence serialization continues to belong to the coordinator, so concurrent tool checkpoints cannot duplicate event sequences. diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md index 6454b496aa..48bd97001d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -`dsh-session-checkpoint-policy` 以零配置插件的形式与持久化后端共同加载,并负责语义持久性屏障。在 `agent/step` 时,该插件会在推导下一个请求前刷新待持久化的提示词输入或前一批响应/结果。该插件惰性包装 `llm/stream`,在记录 `request/header` 之后、构造适配器流之前,刷新活动会话。该插件还在有序的执行前策略之后包装顶层 `tools/execute`,在进入工具主体前刷新已记录的 `tool/call`;嵌套分发则复用外层模型可见调用。循环的最终 `turn/end` 检查点仍是轮次的收尾边界,并会在处理另一个已排队轮次或观察到空闲状态之前完成。 +`dsh-session-checkpoint-policy` 以零配置插件的形式与持久化后端共同加载,并负责语义持久性屏障。在 `agent/pre-step` 时,该插件会在推导下一个请求前刷新待持久化的提示词输入或前一批响应/结果。该插件惰性包装 `llm/stream`,在记录 `request/header` 之后、构造适配器流之前,刷新活动会话。该插件还在有序的执行前策略之后包装顶层 `tools/execute`,在进入工具主体前刷新已记录的 `tool/call`;嵌套分发则复用外层模型可见调用。循环的最终 `turn/end` 检查点仍是轮次的收尾边界,并会在处理另一个已排队轮次或观察到空闲状态之前完成。 -持久化与检查点调度仍是相互独立的 Cordis 插件。后端使请求的 `session/flush` 边界持久化,但不选择边界;只加载后端而不加载本策略仍是有效组合,并保留循环提供的较粗检查点。第一方持久化应用与运行时会显式加载两者,专用部署则可以有意省略或替换本策略。注册顺序决定其他 `agent/step` 监听器追加的事件是否先于本检查点;提示词输入以及前一批由循环自身记录的助手消息与有序结果都已在日志中。 +持久化与检查点调度仍是相互独立的 Cordis 插件。后端使请求的 `session/flush` 边界持久化,但不选择边界;只加载后端而不加载本策略仍是有效组合,并保留循环提供的较粗检查点。第一方持久化应用与运行时会显式加载两者,专用部署则可以有意省略或替换本策略。注册顺序决定其他 `agent/pre-step` 监听器追加的事件是否先于本检查点;提示词输入以及前一批由循环自身记录的助手消息与有序结果都已在日志中。 检查点失败与取消在副作用边界上采取失败关闭策略。请求检查点被拒绝时,系统不会分发给适配器;工具检查点被拒绝时,系统会返回错误结果,不调用工具主体。如果在工具检查点等待期间收到取消,策略会重新检查信号,并返回标准的 `ABORTED_BEFORE_DISPATCH` 结果。步骤间检查点被拒绝时,系统会在发起下一个模型请求前结束该轮次。轮次的最终检查点被拒绝时,系统会实时报告该失败,但不会阻止后续排队工作。持久化写入的串行化仍由协调器负责,因此并发的工具检查点不会产生重复的事件序号。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index 414c63211a..3e397f0081 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md -2026-06-18-compaction-capability-seam.md: 3c219b734e148b963fb5857de89c16f28c2bd402 -2026-06-18-compaction-capability-seam.zh.md: b2c7e9720b596705b60a284e6ccf1448a782b7fc +2026-06-18-compaction-capability-seam.md: f6c5f0bc6bfc947745728526e8562e13d9a74cc1 +2026-06-18-compaction-capability-seam.zh.md: 296dad46ab70704bc25fef0e7f99d899cd881559 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index 3c219b734e..f6c5f0bc6b 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -37,13 +37,13 @@ An earlier draft put the full algorithm (the retention walk, token-summing, text ### Automatic pressure runs after successful durable step work -Successful-call pressure cannot run at pre-step because final `agent/request` routing, provider output, tool results, buffered context, and steering do not exist there. Serial `agent/post-step(agent, turn, step, signal)` fires after those facts are durable and before `step/end`. `dsh-compact-basic` measures the canonical logged request through `ctx.tokenMeter`, so the next request sees any replacement without a speculative envelope override. Once pressure qualifies, optional `ctx.toolResultPrune` rewriting runs before summary selection; compact-basic remeasures the durable surface and skips summarization if pruning restores safe pressure. +Successful-call pressure runs at the next `agent/pre-step`, after the preceding response, tool results, buffered context, and steering are durable and before the next request is derived. `dsh-compact-basic` measures the canonical logged request through `ctx.tokenMeter`, so the next request sees any replacement without a speculative envelope override. Once pressure qualifies, optional `ctx.toolResultPrune` rewriting runs before summary selection; compact-basic remeasures the durable surface and skips summarization if pruning restores safe pressure. Canonical provider context overflow takes a separate path. The failed step closes and `agent/request-error` receives the original request error. Compact-basic owns its per-agent overflow count, prunes before forcing one useful balanced reduction, and returns `{ kind: 'retry' }` only if `session.surface.replaceGeneration` increases, including pruning-only progress when no summary range exists. The loop then closes the failed turn, opens a new numbered retry turn, and reconstructs its request from the durable log. No replacement, a recovery failure before any replacement, cancellation, an exhausted cap, or an unrelated error preserves the original provider failure. If pruning already advanced the generation before later summary work fails, recovery retries from that durable pruned surface unless cancellation or disposal wins. The complete lifecycle decision is in the [after-call recovery Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md). ``` assistant/message → tool/result/context/steering -await serial agent/post-step ⟵ pressure compaction inside the successful step +await waterfall agent/pre-step ⟵ pressure compaction before the next request step/end provider overflow → step/end @@ -118,7 +118,7 @@ Two failure paths, both documented: ## Consequences - **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, and `compact-tool-result-prune` supplies optional deterministic rewriting. `packages/llm/token-meter` owns replay-aware measurement independently. The consumer tier is deferred. -- **Automatic seams**: `agent/post-step` (`@mode serial`) handles successful-call pressure and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Generic `agent/pre-step` remains a four-argument checkpoint with no compaction-only prompt/prefix payload. +- **Automatic seams**: `agent/pre-step` (`@mode waterfall`) handles pressure before request derivation and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Pre-step receives the claimed batch and `PreStepContext`, with no compaction-only prompt/prefix payload. - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. - **`dsh-compact`** owns `COMPACT_CHECKPOINT_SOURCE`, `isCompactCheckpointSource(source)`, `toolPairingBalancedBefore(session, seq)`, and `toolPairingBalancedAfter(session, seq)`. The marker identifies replacement summaries across backend implementations. The cached surface-edge checks prevent `compactRegion` and `compactIfNeeded` from splitting a tool-call/result pair, validate current membership by seq, answer both edges from one per-cut balance sequence, and reject stale or missing seqs and orphan results. - **`dsh-session`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. Its invariant companion treats fresh appended tool results as executions that require an open step and pending call; validated replacements remain turn-enclosed rewrites. diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index b2c7e9720b..296dad46ab 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -37,13 +37,13 @@ Status: implemented ### 成功的持久步骤工作完成后运行自动压力检查 -成功调用的压力检查不能在步骤前运行,因为最终的 `agent/request` 路由、提供方输出、工具结果、缓冲上下文与 steering 当时尚不存在。串行的 `agent/post-step(agent, turn, step, signal)` 会在这些事实持久化后、`step/end` 之前触发。`dsh-compact-basic` 通过 `ctx.tokenMeter` 测量规范的已记录请求,因此下一个请求无需推测性覆盖信封即可看到任何替换。压力达到条件后,可选的 `ctx.toolResultPrune` 重写在摘要范围选择前运行;compact-basic 重新测量持久 surface,如果修剪恢复到安全压力便跳过摘要生成。 +成功调用的压力检查在下一个 `agent/pre-step` 运行;此时前一响应、工具结果、缓冲上下文与 steering 已经持久化,而下一个请求尚未派生。`dsh-compact-basic` 通过 `ctx.tokenMeter` 测量规范的已记录请求,因此下一个请求无需推测性覆盖信封即可看到任何替换。压力达到条件后,可选的 `ctx.toolResultPrune` 重写在摘要范围选择前运行;compact-basic 重新测量持久 surface,如果修剪恢复到安全压力便跳过摘要生成。 规范的提供方上下文溢出走另一条路径。失败步骤先关闭,`agent/request-error` 接收原始请求错误。compact-basic 自行持有按 agent 计的溢出次数,在强制执行一次有效且平衡的缩减前先修剪,且仅当 `session.surface.replaceGeneration` 增加时才返回 `{ kind: 'retry' }`;这包括没有摘要范围时仅修剪取得的进展。随后循环关闭失败轮次,开启新的编号重试轮次,并从持久日志重建请求。没有替换、任何替换前的恢复失败、取消、耗尽的上限或无关错误都会保留原始提供方失败。如果修剪已经推进 generation,而后续摘要工作失败,恢复会从该持久的已修剪 surface 重试,除非取消或资源释放胜出。完整生命周期决策见[调用后恢复 Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)。 ``` assistant/message → tool/result/context/steering -await serial agent/post-step ⟵ pressure compaction inside the successful step +await waterfall agent/pre-step ⟵ pressure compaction before the next request step/end provider overflow → step/end @@ -118,7 +118,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab ## 后果 - **包**:`packages/compact/compact` 提供接口,`compact-basic` 提供后端,`compact-tool-result-prune` 提供可选的确定性重写。`packages/llm/token-meter` 独立拥有回放感知的测量。消费方层推迟。 -- **自动 seam**:`agent/post-step`(`@mode serial`)处理成功调用的压力,`agent/request-error`(`@mode waterfall`)处理失败步骤关闭后的最终请求失败。通用 `agent/pre-step` 保持为四参数检查点,不携带压缩专属的提示词/前缀 payload。 +- **自动 seam**:`agent/pre-step`(`@mode waterfall`)在请求派生前处理压力,`agent/request-error`(`@mode waterfall`)处理失败步骤关闭后的最终请求失败。pre-step 接收已领取批次与 `PreStepContext`,不携带压缩专属的提示词/前缀 payload。 - **`SessionEventMap`** 通过可合并扩展的声明合并获得 `compact/start` / `compact/summary` / `compact/end`;`SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。 - **`dsh-compact`** 拥有 `COMPACT_CHECKPOINT_SOURCE`、`isCompactCheckpointSource(source)`、`toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`。该标记用于跨后端实现识别替换摘要。带缓存的 surface 边缘检查会防止 `compactRegion` 和 `compactIfNeeded` 拆分工具调用/结果对,按 seq 校验当前成员关系,从每个切割点的一条平衡序列回答两侧边缘,并拒绝陈旧或缺失的 seq 与孤立结果。 - **`dsh-session`** 通过唯一的 surface 管理器校验位置替换、完整溯源信息和仅内容的单节点 `tool/result` 重写。其不变式配套插件将新追加的工具结果视为执行,要求存在已打开的步骤与待处理调用;已校验的替换仍是位于轮次内的重写。 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 073aa9fa4b..1035b197e4 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-24-workspace-context.md -2026-06-24-workspace-context.md: 8baced0143abb38ff34d16a072761ec016a53d6e -2026-06-24-workspace-context.zh.md: 392d57f344b97c1816f691fef75440f815bccb50 +2026-06-24-workspace-context.md: 5b55f9173d11ad248c29fc736df092cb3600d7ef +2026-06-24-workspace-context.zh.md: b7eff81ade5bd45c57a8775cd301fa63e8489b8f diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 8baced0143..5b55f9173d 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -14,7 +14,7 @@ The lifecycle has two distinct classes of content. The initial applicable chain ## Decision -The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/step`, `tools/post-execute`, and the optional `ctx.fs` capability. +The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/pre-step`, `tools/post-execute`, and the optional `ctx.fs` capability. The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes resolve each path and stat the result, so a final-component symlink is followed to its target: a link to a regular file loads, while a missing path or a non-file target is a confirmed absence. Following repository-owned links across the trust boundary is a deliberate reversal of the original no-follow probe; the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns that decision and its residual risk. The step signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A resolve or stat exception is classified as unavailable: it skips only that candidate and is never interpreted as the deletion of an already-loaded scope. @@ -28,9 +28,9 @@ The user-global file is fixed at `$DSH_HOME/AGENTS.md`, is not affected by eithe ### Baseline Injection -At the first `agent/step` of an agent-loop instance, the plugin injects one sourced user-role message before the request is derived. It loads the user-global file first, then finds the project root by walking upward from `agent.session.header.cwd` to a configured root marker (default `.git`), then loads one candidate from each directory from the root to the cwd. A `.git` file and a `.git` directory are both valid markers, covering linked worktrees and submodules. Without a marker, the cwd itself is the root. +At the first `agent/pre-step` of an agent-loop instance, the plugin composes one sourced user-role baseline and prepends it directly to the agent's next-step inbox without changing the current decision. It loads the user-global file first, then finds the project root by walking upward from `agent.session.header.cwd` to a configured root marker (default `.git`), then loads the configured candidates from each directory from the root to the cwd. A `.git` file and a `.git` directory are both valid markers, covering linked worktrees and submodules. Without a marker, the cwd itself is the root. Because the loop claims before pre-step, the baseline may enter a later request. -The injection becomes a durable `user/message` with a typed `workspace-instructions` source. Its `baseline: true` marker distinguishes the complete startup or resume baseline from later deltas, and its change list persists the included scopes and content digests. In the product spine workspace instructions are registered before the skills catalog, so their `agent/step` listener injects first. The loop drains both messages before deriving the first request. +When a later pre-step claims and enters it, the baseline becomes a durable `user/message` with a typed `workspace-instructions` source. Its `baseline: true` marker distinguishes the complete startup or resume baseline from later deltas, and its change list persists the included scopes and content digests. If a previously queued workspace baseline is still pending, the plugin removes that exact message and prepends its replacement instead of accumulating duplicates. A resumed agent creates a new loop instance and injects a baseline composed from current files before its first request. This permits current baseline content on resume without mutating an earlier history event. A resume and a hot plugin remount both face a log that may already hold a baseline; they are told apart by `agent/session-start`, which a startup or resume emits before the first step while a remount attaches to an already-live session and never sees it. A remount retains the existing baseline only when its typed event remains in the current visible surface, and still rebuilds scope and provider-version tracking from current files. If compaction has shadowed that event, the remount injects a current baseline. A resume always re-composes. @@ -68,7 +68,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc **Use a global `ctx.systemPrompt.section()`.** Rejected because one Cordis context can host sessions with different cwd values, while repository-owned text is lower-authority context rather than top-authority provider system content. -**Inject the baseline on every `agent/step`.** Rejected because repeated history injection wastes tokens and complicates duplicate state. A per-mount session guard gives one visible baseline event while it remains on the surface; dynamic append-only messages handle changes and compaction re-arming. +**Return the baseline in every `agent/pre-step` decision.** Rejected because workspace context needs only eventual next-step delivery. Direct inbox replacement keeps one pending baseline without coupling it to the request already claimed before the listener ran. **Load both `AGENTS.md` and `CLAUDE.md` in one directory.** Rejected because repositories in transition commonly duplicate guidance across both files. Ordered candidates make precedence explicit and configurable. diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index 392d57f344..b7eff81ade 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -14,7 +14,7 @@ Status: implemented ## 决策 -该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/step`、`tools/post-execute` 和可选的 `ctx.fs` 功能。 +该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/pre-step`、`tools/post-execute` 和可选的 `ctx.fs` 功能。 插件不会静态注入 `fs`。因此,不带提供方的产品树仍能正常启动;在文件系统提供方出现之前,插件保持无操作。所有生产读取都通过该提供方完成。候选项探测会解析每个路径并对结果执行 stat,因此会跟随最终路径组件的符号链接至其目标:指向普通文件的链接会被加载,缺失路径或非文件目标则确认为不存在。允许仓库拥有的链接跨越信任边界,是对最初不跟随探测方式的刻意反转;[跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明该决策及其残余风险。步骤信号与动态工具执行信号会贯穿解析、元数据探测和流式读取,因此取消不会等待无关的文件系统扫描。解析或 stat 异常归类为不可用:它只跳过该候选项,绝不被解释为已经加载的作用域被删除。 @@ -28,9 +28,9 @@ Status: implemented ### 基线注入 -在 agent loop(智能体循环)实例的第一个 `agent/step`,插件会在派生请求前注入一条带来源的 user 角色消息。它先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录各加载一个候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。 +在 agent loop(智能体循环)实例的第一个 `agent/pre-step`,插件会组合一条带来源的 user 角色基线,将它直接 prepend 到 agent 的 next-step inbox,且不修改当前决策。它先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录加载已配置候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。由于循环先领取、后运行 pre-step,该基线可能进入后续请求。 -该注入成为一条持久 `user/message`,并携带带类型的 `workspace-instructions` 来源。其 `baseline: true` 标记将完整的启动或恢复基线与后续增量区分开来,变更列表则持久保存已纳入的作用域和内容 digest。在产品主干中,工作区指令的注册先于 skill 目录,所以其 `agent/step` 监听器先注入。循环会在派生第一次请求前 drain 这两条消息。 +后续 pre-step 领取并让它进入步骤时,该基线成为一条持久 `user/message`,并携带带类型的 `workspace-instructions` 来源。其 `baseline: true` 标记将完整的启动或恢复基线与后续增量区分开来,变更列表则持久保存已纳入的作用域和内容 digest。若先前排队的 workspace 基线仍在等待,插件会删除该确切消息并 prepend 替代值,而不会累积副本。 恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。恢复与插件热重挂都会面对日志中可能已存在基线的情况;二者通过 `agent/session-start` 区分:启动或恢复会在第一步前发出该事件,而热重挂附着到一个已存活的会话、永远不会看到它。只有当基线的类型化事件仍在当前可见表层中时,热重挂才保留既有基线,同时仍会根据当前文件重建 scope 与提供方版本跟踪。如果压缩(compaction)已遮蔽该事件,热重挂会注入当前基线。恢复则始终重新组合。 @@ -68,7 +68,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, **使用全局 `ctx.systemPrompt.section()`。** 不予采纳,因为同一个 Cordis 上下文可以承载 cwd 不同的多个会话,而仓库所有的文本属于低权威用户上下文,不是最高权威的提供方系统内容。 -**在每次 `agent/step` 时注入基线。** 不予采纳,因为重复注入历史会浪费 token,并使重复状态复杂化。逐挂载会话防护会在基线事件仍留在表面期间提供一条可见基线事件;动态仅追加消息负责处理变更和压缩后的重新启用。 +**在每次 `agent/pre-step` 决策中返回基线。** 不予采纳,因为 workspace context 只需要最终进入后续步骤。直接替换 inbox 可以只保留一条待处理基线,也不会把它耦合到监听器运行前已经领取的请求。 **在一个目录中同时加载 `AGENTS.md` 和 `CLAUDE.md`。** 不予采纳,因为正在迁移的仓库通常会在两个文件中重复指引。按顺序排列的候选项让优先级显式且可配置。 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index 809c14dedb..5790831410 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-bridges.md -2026-06-30-hook-bridges.md: 99c6b1941a10e198ec3028f5fe505dabfff9abbe -2026-06-30-hook-bridges.zh.md: 66855c3c4f36877aa627173de8e73250546e9621 +2026-06-30-hook-bridges.md: 39164c8462391baab1e92a54d80cfbc2963f3157 +2026-06-30-hook-bridges.zh.md: 5565b04b3985dea11133387904dd2fb1ba35103d diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md index 99c6b1941a..39164c8462 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md @@ -6,7 +6,7 @@ English | [中文](2026-06-30-hook-bridges.zh.md) ## Problem -The harness's extension surface is its typed interception seams ([the interception-seams Agent Note](2026-06-30-interception-seams.md)): a "native hook" is just an ordinary cordis plugin subscribing to `agent/session-start`, `agent/prompt-submit`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-stopping`, `subagent/start`, or `subagent/end`. But users arrive with **existing** Claude Code (CC) and Codex hook configs — a `hooks.json` (or a settings file's `hooks` key) full of shell-command hooks — and want those to run unmodified. This Agent Note introduces the two **bridge plugins** that translate that external shell-hook protocol onto the typed seams, built on the shared wire-protocol library ([the hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md)). +The harness's extension surface is its typed interception seams ([the interception-seams Agent Note](2026-06-30-interception-seams.md)): a "native hook" is just an ordinary cordis plugin subscribing to `agent/session-start`, `agent/pre-step`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-stopping`, `subagent/start`, or `subagent/end`. But users arrive with **existing** Claude Code (CC) and Codex hook configs — a `hooks.json` (or a settings file's `hooks` key) full of shell-command hooks — and want those to run unmodified. This Agent Note introduces the two **bridge plugins** that translate that external shell-hook protocol onto the typed seams, built on the shared wire-protocol library ([the hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md)). The framing that shapes the whole design: **a bridge is a compatibility adapter, not a power tool.** Anything a bridge does (block a tool, inject context, force continuation, observe a subagent) a native cordis plugin does more powerfully — typed returns, full `ctx`, no serialization boundary. The bridge's reason to exist is to run the explicitly supported subset of external CC/Codex command hooks. That keeps each bridge thin: parse the config, pick a matcher mode, build the per-event payload, call `runHook` + `mergeHookOutputs` from the shared lib, and map the neutral outcome onto a seam Decision. The package READMEs own the exact current unsupported-event and partial-field inventory against the official protocols. @@ -24,7 +24,7 @@ Each bridge maps the neutral `MergedHookOutcome` from the shared lib onto the se | Seam | CC | Codex | |---|---|---| | `agent/session-start` (emit) | additionalContext → `agent.inject()` | plain-stdout output → additionalContext → `agent.inject()` | -| `agent/prompt-submit` | `deny`→`block`; context-only→delegate+fold | `block`→`block`; context-only→delegate+fold | +| `agent/pre-step` | `deny`→`reject`; context-only→delegate+fold into `enter` | `block`→`reject`; context-only→delegate+fold into `enter` | | `tools/pre-execute` | `deny`→`deny`; `ask`→`ask` | `block`→`deny` (no allow/ask) | | `tools/post-execute` | `deny`→`block`+feedback; context-only→delegate+fold | same | | `agent/turn-stopping` | blocking Stop → next-step steering | same | @@ -37,11 +37,11 @@ The CC bridge's `ask` result is a real permission path, not a terminal bridge de Every bridge `inject()` and additional-context input explicitly passes `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`. Unit coverage pins the resulting `user/message.source` as the plugin rather than the user. -`UserPromptSubmit` runs during admission, before any turn opens. It therefore writes no turn-scoped `hook/invoked` / `hook/result` pair: a block leaves no transcript, while allowed additional context is durably represented by its sourced `user/message`. The Codex payload still receives the candidate next `turn_id`; rejection does not consume that number. +`UserPromptSubmit` runs at pre-step. For an initial follow-up it executes before any turn opens and therefore writes no turn-scoped `hook/invoked` / `hook/result` pair: rejection leaves the claimed input removed with no transcript, while entered additional context is durably represented by its sourced `user/message`. The Codex payload still receives the candidate next `turn_id`; rejection does not consume that number. ### Adding context is not a veto — delegate, then prepend -A hook that only attaches `additionalContext` (no block/deny) is NOT a decision the bridge should return on its own: returning `allow`/`accept` from a waterfall listener WITHOUT calling `next()` short-circuits every later `agent/prompt-submit` / `tools/post-execute` listener, so a policy/sandbox plugin registered after the bridge would never see the prompt. Each bridge therefore delegates via `next()` before adding its context to the downstream decision. Both seams carry ordered `additionalContexts` arrays, so the bridge prepends its separately sourced entry while preserving every downstream source, envelope, and metadata field; a downstream prompt block still drops all context because the prompt never reaches the model, while post-tool block semantics may explicitly retain contexts. Code Mode ferries the same array through the outer `run_code` result. Only a real `deny`/`block` from the hook itself short-circuits. Tests assert a later listener can still block a prompt a context-only hook allowed and that retained prompt and post-tool contexts remain separate. +A hook that only attaches `additionalContext` (no block/deny) is NOT a decision the bridge should return on its own: returning `enter` from a waterfall listener WITHOUT calling `next()` short-circuits every later `agent/pre-step` / `tools/post-execute` listener, so a policy/sandbox plugin registered after the bridge would never see the prompt. Each bridge therefore delegates via `next()` before adding its context to a downstream enter decision. The bridge preserves every downstream message, while a downstream pre-step rejection drops the whole claimed batch because no step opens. Post-tool decisions retain their independent ordered `additionalContexts` semantics, including Code Mode deferral through the outer `run_code` result. Only a real `deny`/`block` from the hook itself short-circuits. Tests assert a later listener can still reject a prompt after a context-only hook and that retained prompt and post-tool contexts remain separate. ### CLAUDE_PROJECT_DIR defaults to the session workspace diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index 66855c3c4f..5565b04b39 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note](2026-06-30-interception-seams.md)):所谓「原生钩子」不过是一个普通的 Cordis 插件,订阅 `agent/session-start`、`agent/prompt-submit`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-stopping`、`subagent/start` 或 `subagent/end`。但用户带着**既有的** Claude Code(CC)和 Codex 钩子配置到来,一个 `hooks.json`(或 settings 文件中的 `hooks` 键)里满是 shell 命令钩子,并希望它们原样运行。本 Agent Note 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上,构建于共享的协议格式(wire format)库之上(见 [hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md))。 +harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note](2026-06-30-interception-seams.md)):所谓「原生钩子」不过是一个普通的 Cordis 插件,订阅 `agent/session-start`、`agent/pre-step`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-stopping`、`subagent/start` 或 `subagent/end`。但用户带着**既有的** Claude Code(CC)和 Codex 钩子配置到来,一个 `hooks.json`(或 settings 文件中的 `hooks` 键)里满是 shell 命令钩子,并希望它们原样运行。本 Agent Note 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上,构建于共享的协议格式(wire format)库之上(见 [hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md))。 贯穿整个设计的定位:**桥接是兼容性适配器,不是高级工具。** 桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent),原生 Cordis 插件都能做得更强——类型化返回值、完整 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射为 seam Decision。各包的 README 维护着当前不支持的事件和部分字段的完整清单,以官方协议为参照。 @@ -24,7 +24,7 @@ harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note]( | Seam | CC | Codex | |---|---|---| | `agent/session-start`(emit) | additionalContext → `agent.inject()` | 纯 stdout 输出 → additionalContext → `agent.inject()` | -| `agent/prompt-submit` | `deny`→`block`;仅上下文→delegate+fold | `block`→`block`;仅上下文→delegate+fold | +| `agent/pre-step` | `deny`→`reject`;仅上下文→委托并折叠到 `enter` | `block`→`reject`;仅上下文→委托并折叠到 `enter` | | `tools/pre-execute` | `deny`→`deny`;`ask`→`ask` | `block`→`deny`(无 allow/ask) | | `tools/post-execute` | `deny`→`block`+feedback;仅上下文→delegate+fold | 同上 | | `agent/turn-stopping` | 阻塞的 Stop → 下一步 steering(中途引导) | 同上 | @@ -37,11 +37,11 @@ CC 桥接的 `ask` 结果是一条真正的权限路径,而非终态桥接决 每个桥接的 `inject()` 和 additional-context 输入都显式传入 `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`。单元测试覆盖率固定验证结果中的 `user/message.source` 为插件而非用户。 -`UserPromptSubmit` 在准入阶段运行,早于任何轮次开启。因此它不写入任何轮次范围的 `hook/invoked` / `hook/result` 对:阻止不会留下 transcript(文本记录),而被允许的额外上下文由其带来源的 `user/message` 持久呈现。Codex payload 仍会收到候选的下一个 `turn_id`;拒绝不会消耗该编号。 +`UserPromptSubmit` 在 pre-step 运行。对于初始 follow-up,它发生在任何轮次开启前,因此不会写入轮次范围的 `hook/invoked` / `hook/result` 对:reject 让已领取输入保持删除且不留下 transcript(文本记录),而进入步骤的额外上下文由其带来源的 `user/message` 持久呈现。Codex payload 仍会收到候选的下一个 `turn_id`;reject 不会消耗该编号。 ### 添加上下文不是否决——先 delegate,再 prepend -仅附加 `additionalContext`(没有 block/deny)的钩子并不是桥接可以独自返回的决策:在 waterfall 监听器中不调用 `next()` 就返回 `allow`/`accept`,会短路其后的每个 `agent/prompt-submit` / `tools/post-execute` 监听器,使注册在桥接之后的策略/沙箱插件看不到该提示词。因此,每个桥接都会先通过 `next()` 委托,再将自身上下文加入下游决策。两个 seam 都携带有序的 `additionalContexts` 数组,因此桥接会在保留所有下游来源、信封和元数据字段的同时,前置加入其独立来源的条目;下游提示词阻止仍会丢弃所有上下文,因为提示词从未到达模型,而工具后阻止语义可以显式保留上下文。Code Mode 会通过外层 `run_code` 结果转送同一数组。只有钩子本身真正返回 `deny`/`block` 才会短路。测试断言:上下文钩子允许后,较晚的监听器仍能阻止提示词,且保留的提示词和工具后上下文仍彼此分离。 +仅附加 `additionalContext`(没有 block/deny)的钩子并不是桥接可以独自返回的决策:在 waterfall 监听器中不调用 `next()` 就返回 `enter`,会短路其后的每个 `agent/pre-step` / `tools/post-execute` 监听器,使注册在桥接之后的策略/沙箱插件看不到该提示词。因此,每个桥接都会先通过 `next()` 委托,再将自身上下文加入下游 enter 决策。桥接会保留所有下游消息;下游 pre-step reject 会丢弃整个已领取批次,因为步骤从未打开。工具后决策仍保留独立的有序 `additionalContexts` 语义,包括 Code Mode 通过外层 `run_code` 结果延迟上下文。只有钩子本身真正返回 `deny`/`block` 才会短路。测试断言:仅上下文钩子之后,较晚的监听器仍能 reject 提示词,且保留的提示词和工具后上下文仍彼此分离。 ### CLAUDE_PROJECT_DIR 默认为会话工作区 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 3ecd4e2dbe..34f6a0e3fd 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md -2026-06-30-hook-protocol-lib.md: ce25f40e96ffd5c319d9845e36eab5130cec5857 -2026-06-30-hook-protocol-lib.zh.md: 062160931f52576e65557b6e0d385ccaac54aceb +2026-06-30-hook-protocol-lib.md: 2edff0d501cd7695873c68054eeb3a1e9942eced +2026-06-30-hook-protocol-lib.zh.md: 675612e3f6b41173e39f473d10f1b7977eeeb3da diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index ce25f40e96..2edff0d501 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -21,7 +21,7 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo - **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order. - **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and owner-defined execution relation stay consistent across bridges. `appendHookResult` also owns the durable record's semantics — the decision string (the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`) and the 500-character `stderrSummary` truncation derive from the `HookOutput` here, not per-bridge. -**Per-dialect (the bridge plugins):** building each event's stdin payload (CC's base+per-event field sets vs Codex's snake_case with `turn_id`/`model` extras), the dialect's env + `${CLAUDE_PLUGIN_ROOT}` substitution (CC) vs none (Codex), and mapping the neutral `HookOutput`/`MergedHookOutcome` onto the harness's seam-specific typed Decisions (`PreToolDecision`, `PromptDecision`, `ContinuationDecision`, `PostToolDecision`). +**Per-dialect (the bridge plugins):** building each event's stdin payload (CC's base+per-event field sets vs Codex's snake_case with `turn_id`/`model` extras), the dialect's env + `${CLAUDE_PLUGIN_ROOT}` substitution (CC) vs none (Codex), and mapping the neutral `HookOutput`/`MergedHookOutcome` onto the harness's seam-specific typed Decisions (`PreToolDecision`, `PreStepDecision`, `ContinuationDecision`, `PostToolDecision`). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 062160931f..675612e3f6 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -21,7 +21,7 @@ Status: implemented - **合并** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,阻止原因以 `\n\n` 拼接,context/system-messages 按序累积。 - **`hook/*` 会话事件** — `hook/invoked` / `hook/result`,通过声明合并进入 `SessionEventMap`(仅日志,如 `compact/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与由所有方定义的执行关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义:decision 字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库的 `HookOutput` 派生,而非各桥接插件各自实现。 -**方言专属(桥接插件):** 构建每个事件的 stdin payload(CC 的 base+per-event 字段集 vs Codex 的 snake_case 加 `turn_id`/`model` 额外字段)、方言的 env 与 `${CLAUDE_PLUGIN_ROOT}` 替换(CC)vs 无替换(Codex),以及将方言无关的 `HookOutput`/`MergedHookOutcome` 映射为 harness seam 专属的类型化 Decision(`PreToolDecision`、`PromptDecision`、`ContinuationDecision`、`PostToolDecision`)。 +**方言专属(桥接插件):** 构建每个事件的 stdin payload(CC 的 base+per-event 字段集 vs Codex 的 snake_case 加 `turn_id`/`model` 额外字段)、方言的 env 与 `${CLAUDE_PLUGIN_ROOT}` 替换(CC)vs 无替换(Codex),以及将方言无关的 `HookOutput`/`MergedHookOutcome` 映射为 harness seam 专属的类型化 Decision(`PreToolDecision`、`PreStepDecision`、`ContinuationDecision`、`PostToolDecision`)。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml index 5845297101..cf475337bd 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-interception-seams.md -2026-06-30-interception-seams.md: 4658983e1f098ecd199eecec4408e7c2f134cbf7 -2026-06-30-interception-seams.zh.md: 13b7c56829412773111fcf6d75cc717c51d49c7b +2026-06-30-interception-seams.md: 07d4ca28030ac5ba8a8c9325851fc34c3072e786 +2026-06-30-interception-seams.zh.md: b71bccdb684c34fb55b11c1b7b0eca7892b23155 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md index 4658983e1f..07d4ca2803 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md @@ -16,7 +16,7 @@ The canonical surface separates transformable policy, around-dispatch control, a **Agent events** (`dsh-agent`): - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. -- `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` — waterfall, fired for one claimed queued message before the loop opens a turn or appends `user/message`. The explicit admission signal is placed before the final `next`; `allow` optionally rewrites the prompt `content` or attaches separately sourced `additionalContexts[]`, while `block` discards the candidate without creating session history. +- `agent/pre-step(agent, messages, context, next) → PreStepDecision` — waterfall, fired before every proposed step after the loop has atomically removed its exclusive inbox batch. `PreStepContext` carries that request's `turn`, `step`, and cancellation `signal`; `messages` is empty for a tool continuation with no intervening input. `enter` returns the complete message batch, including any current-request context a listener contributes; `reject` opens no step and leaves the claimed messages removed. **`agent/turn-stopping`** is an awaited notification at the natural stop boundary. A listener that needs another step calls `agent.steer()` with explicitly sourced model-facing content; the loop then re-reads the outbox and either continues or closes the turn. @@ -35,7 +35,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li ### Three load-bearing loop decisions -1. **Run prompt policy before opening the turn.** A blocked prompt creates no turn or durable event. On allow, the loop stages the rewritten prompt followed by every returned `additionalContexts` entry, opens the turn, and drains that outbox before the first step. Each claimed ordinary-send item is the sole direct prompt in its turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md). +1. **Run pre-step policy at every proposed step.** The initial decision runs before the loop opens its turn, so rejection creates no turn or durable message. A tool continuation with no newly claimed input still submits an empty batch, allowing per-request context producers to add logged messages to that exact request. On enter, the loop opens the step and appends the returned batch as `user/message` events before request derivation. Each claimed follow-up remains the sole direct prompt in its turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md). 2. **Post-tool `additionalContexts` and asynchronous injections enter the active-batch FIFO and append when that batch settles.** `content`/`feedback` shape the result `execute()` returns, but each context is a separate sourced `user/message`, and a single step or composite tool can produce many. Appending context immediately would interleave `result(c1) → context → result(c2)` or place nested context before its outer result, breaking tool-call/result adjacency. `ToolRunContext.deferContext()` therefore collects nested-dispatch context through failures, `execute()` surfaces the ordered array on `ToolExecutionResult`, and the loop accepts it into the same FIFO as `agent.inject()` calls made during execution. The FIFO appends after every recorded result when the batch settles, including before an interrupted turn closes. An accepted outer call preserves deferred contexts before decision contexts; an outer block discards deferred contexts and exposes only contexts explicitly supplied by the blocking decision. @@ -56,4 +56,4 @@ The seam package does **not** declare `hook/*` session events (the durable hook- ## Consequences -The canonical interception surface is uniformly typed without giving every extension the same power: hooks return decisions, execution wrappers wrap, terminal guards only deny, and final observers only observe. The loop owns session-start, pre-turn prompt admission, post-tool context buffering, and stopping; `dsh-tools` owns identity sealing and the five-phase execution pipeline. Their contracts are documented in [architecture.md](../../../../docs/architecture.md), package READMEs, [core interception decisions](../../../../docs/core-data-structures/core.md#interception-decisions), and [tool structures](../../../../docs/core-data-structures/tools.md). The ACP bridge settles an admission rejection as `cancelled` after the agent becomes idle with no owned turn, while hook-driven snapshots verify the observable bridge behavior end to end. +The canonical interception surface is uniformly typed without giving every extension the same power: hooks return decisions, execution wrappers wrap, terminal guards only deny, and final observers only observe. The loop owns session-start, pre-step claim settlement, post-tool context buffering, and stopping; `dsh-tools` owns identity sealing and the five-phase execution pipeline. Their contracts are documented in [architecture.md](../../../../docs/architecture.md), package READMEs, [core interception decisions](../../../../docs/core-data-structures/core.md#interception-decisions), and [tool structures](../../../../docs/core-data-structures/tools.md). The ACP bridge settles an initial pre-step rejection as `cancelled` after the agent becomes idle with no owned turn, while hook-driven snapshots verify the observable bridge behavior end to end. diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md index 13b7c56829..b71bccdb68 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md @@ -16,7 +16,7 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 **Agent 事件**(`dsh-agent`): - `agent/session-start(agent, source)` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource`(`startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。 -- `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` ——waterfall,针对一条取得所有权的排队消息触发,早于循环开启轮次或追加 `user/message`。显式准入 signal 位于最后的 `next` 之前;`allow` 可以重写提示词 `content` 或附加来源各自独立的 `additionalContexts[]`,而 `block` 会丢弃该候选消息,不产生会话历史。 +- `agent/pre-step(agent, messages, context, next) → PreStepDecision` ——waterfall,在每个拟议步骤之前、循环原子移除其独占 inbox 批次后触发。`PreStepContext` 携带该请求的 `turn`、`step` 与取消 `signal`;没有中途输入的工具续步会收到空批次。`enter` 返回完整消息批次,其中包括监听器为当前请求贡献的上下文;`reject` 不打开步骤,并让已领取消息保持已删除。 **`agent/turn-stopping`** 是自然停止边界上的一次 awaited 通知。需要再执行一步的监听器调用 `agent.steer()`,传入来源显式的面向模型的内容(steering,中途引导);循环随后重新读取 outbox,继续执行或关闭轮次。 @@ -35,7 +35,7 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 ### 三个承重的循环决策 -1. **在开启轮次之前运行提示词策略。** 被阻止的提示词不会创建轮次,也不产生持久事件。允许时,循环先暂存重写后的提示词,再暂存每个返回的 `additionalContexts` 条目,然后开启轮次并在第一个步骤之前排空该 outbox。依照[一次 send 对应一个轮次的简化](../simplification/2026-07-17-one-send-one-turn.md),每个取得所有权的 ordinary-send 条目都是其轮次中唯一的直接提示词。 +1. **在每个拟议步骤运行 pre-step 策略。** 首次决策发生在循环开启轮次之前,因此 reject 不会创建轮次,也不产生持久消息。即使工具续步没有新取得所有权的输入,也会提交空批次,使逐请求上下文生产方可以把带日志的消息加入这一次请求。enter 时,循环先开启步骤,再把返回批次作为 `user/message` 追加,然后派生请求。依照[一次 send 对应一个轮次的简化](../simplification/2026-07-17-one-send-one-turn.md),每个已领取 follow-up 仍是其轮次中唯一的直接提示词。 2. **工具执行后的 `additionalContexts` 与异步注入进入活跃批次 FIFO,并在该批次结算时追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但每项上下文都是一条独立的带来源 `user/message`,而单个步骤或组合工具可以产生许多上下文。立即追加上下文会产生 `result(c1) → context → result(c2)` 的交错,或把嵌套上下文放在外层结果之前,破坏工具调用/结果邻接性。因此 `ToolRunContext.deferContext()` 会在失败路径上也收集嵌套调度上下文,`execute()` 在 `ToolExecutionResult` 上暴露有序数组,循环再把它接纳到与执行期间 `agent.inject()` 调用相同的 FIFO 中。FIFO 在批次结算时,于每个已记录结果之后追加,其中也包括被中断轮次关闭之前。被接受的外层调用将 deferred contexts 保留在 decision contexts 之前;被外层阻止时则丢弃 deferred contexts,只暴露阻止 decision 显式提供的上下文。 @@ -56,4 +56,4 @@ seam 包**不**声明 `hook/*` 会话事件(持久的钩子调用日志); ## 后果 -规范拦截表面具有统一的类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终结 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、轮次前的提示词准入、工具执行后上下文缓冲和 stopping;`dsh-tools` 负责身份封存与五阶段执行流水线。它们的契约记录在 [architecture.md](../../../../docs/architecture.md)、各包 README、[核心拦截 decision](../../../../docs/core-data-structures/core.md#interception-decisions) 与[工具结构](../../../../docs/core-data-structures/tools.md)中。ACP 桥接在 agent 空闲且不再拥有轮次后,将准入拒绝结算为 `cancelled`,而钩子驱动的快照端到端验证可观测的桥接行为。 +规范拦截表面具有统一的类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终结 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、pre-step 领取结算、工具执行后上下文缓冲和 stopping;`dsh-tools` 负责身份封存与五阶段执行流水线。它们的契约记录在 [architecture.md](../../../../docs/architecture.md)、各包 README、[核心拦截 decision](../../../../docs/core-data-structures/core.md#interception-decisions) 与[工具结构](../../../../docs/core-data-structures/tools.md)中。ACP 桥接在 agent 空闲且不再拥有轮次后,将初始 pre-step reject 结算为 `cancelled`,而钩子驱动的快照端到端验证可观测的桥接行为。 diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml index ca5cc25241..61bd2fdfa7 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-05-skill-system.md -2026-07-05-skill-system.md: 242650ec8ba64fd0801a958711d5790fae07b259 -2026-07-05-skill-system.zh.md: da5f4af4b2f8bc0144be0a7ed608de7edd9a9947 +2026-07-05-skill-system.md: 6cf78e31a4499f96d7f5b24c503944071577a934 +2026-07-05-skill-system.zh.md: 3cf904284c783f4195be9eaf9d4dd5a248c39c52 diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.md index 242650ec8b..6cf78e31a4 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.md @@ -22,7 +22,7 @@ Each skill is either `/SKILL.md` or `.md` with YAML frontmatter. `na Local skill filesystem I/O goes through `ctx.fs` when a filesystem service is loaded: project-root lookup probes `.git` with `resolve` and `stat`, root discovery uses `listDir`, and skill reads use `readText`. The Node filesystem remains a fallback for minimal contexts that mount `dsh-skill-local` without the fs seam. Missing roots, unreadable or malformed skill files, and transient provider `list()` failures degrade to warn-and-skip so one bad source does not make every agent request fail; malformed candidates still fail fast because they are provider contract violations. -`dsh-tool-skill` injects one durable user-role `` catalog as a sourced `user/message` at the session's first `agent/step`, and only when that agent's tool view resolves this plugin's exact `skill` registration. The catalog contains sorted skill name and description only; it excludes bodies, paths, sources, providers, and routing hints. Descriptions are whitespace-normalized, XML-escaped, and capped by `catalogDescriptionMaxLength`, whose default is `500` and minimum is `3`. Full skill bodies are never included in the catalog. (The catalog originally rode the request-only [session-prefix seam](../../archived/feature/2026-07-07-session-prefix.md), archived; the [unified sourced-message decision](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) moved it into durable history.) +`dsh-tool-skill` injects one durable user-role `` catalog as a sourced `user/message` at the session's first `agent/pre-step`, and only when that agent's tool view resolves this plugin's exact `skill` registration. The catalog contains sorted skill name and description only; it excludes bodies, paths, sources, providers, and routing hints. Descriptions are whitespace-normalized, XML-escaped, and capped by `catalogDescriptionMaxLength`, whose default is `500` and minimum is `3`. Full skill bodies are never included in the catalog. (The catalog originally rode the request-only [session-prefix seam](../../archived/feature/2026-07-07-session-prefix.md), archived; the [unified sourced-message decision](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) moved it into durable history.) The registry's `list()` returns every winning summary, while model and user consumers apply the invocation predicates owned by the [independent invocation-policy decision](2026-07-28-skill-invocation-policy.md). The `skill({ name })` tool loads one model-invocable skill for the current agent cwd and returns a tool result containing ``, ``, and ``. `resourceBase` supplies a directory, URL, or opaque provider-managed base for explicitly referenced scripts, references, and assets; resources load only as needed, without directory enumeration. An unresolved name reports that the skill is unknown or no longer available; invalid names and skills with `invocation.modelInvocable: false` retain distinct tool errors. The tool result is the model-visible disclosure path. diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md index da5f4af4b2..3cf904284c 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md @@ -22,7 +22,7 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和 本地 skill 的文件系统 I/O 在加载了文件系统服务时通过 `ctx.fs` 进行:项目根目录查找使用 `resolve` 和 `stat` 探测 `.git`,根目录发现使用 `listDir`,skill 读取使用 `readText`。Node 文件系统作为后备,供在不挂载 fs seam 的最小上下文中加载 `dsh-skill-local` 时使用。缺失的根目录、不可读或格式错误的 skill 文件、以及提供方 `list()` 的瞬态失败均降级为警告并跳过,使一个坏源不会导致所有 agent 请求失败;格式错误的候选项仍然快速失败,因为它们违反了提供方契约。 -`dsh-tool-skill` 在会话的第一个 `agent/step` 注入一个持久化的 user-role `` 目录,作为带来源的 `user/message`,且仅当该 agent 的工具视图解析到本插件精确的 `skill` 注册时才注入。该目录仅包含排序后的 skill 名称与描述;不包含正文、路径、来源、提供方和路由提示。描述经过空白规范化、XML 转义,并受 `catalogDescriptionMaxLength` 上限约束,其默认值为 `500`,最小值为 `3`。完整的 skill 正文从不包含在目录中。(目录最初通过仅请求的[会话前缀 seam](../../archived/feature/2026-07-07-session-prefix.md)(已归档)传递;[统一带来源消息的决策](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)将其移入持久化历史。) +`dsh-tool-skill` 在会话的第一个 `agent/pre-step` 注入一个持久化的 user-role `` 目录,作为带来源的 `user/message`,且仅当该 agent 的工具视图解析到本插件精确的 `skill` 注册时才注入。该目录仅包含排序后的 skill 名称与描述;不包含正文、路径、来源、提供方和路由提示。描述经过空白规范化、XML 转义,并受 `catalogDescriptionMaxLength` 上限约束,其默认值为 `500`,最小值为 `3`。完整的 skill 正文从不包含在目录中。(目录最初通过仅请求的[会话前缀 seam](../../archived/feature/2026-07-07-session-prefix.md)(已归档)传递;[统一带来源消息的决策](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)将其移入持久化历史。) 注册表的 `list()` 返回全部胜出摘要,而模型与用户消费方应用[独立调用策略决策](2026-07-28-skill-invocation-policy.md)定义的调用判定。`skill({ name })` 工具为当前 agent cwd 加载一个模型可调用的 skill,返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和 `invocation.modelInvocable` 为 `false` 的 skill 保留不同的工具错误。工具结果是面向模型的可见披露路径。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 9311c6cac7..b32d673095 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: 42b78ad8341dd52c4dd146a2207a5ae909d28f1e -2026-07-06-sandbox.zh.md: dfa3349e4d74d6f2c4944414c25fe3726d4a9b5a +2026-07-06-sandbox.md: 5e4c34d397f0e7f6d977c7a5ab64e9ee62329bd5 +2026-07-06-sandbox.zh.md: 4e1d6b341967c0d5caadd4295d15fe99ce263617 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 42b78ad834..5e4c34d397 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -149,7 +149,7 @@ Each phase gets its full design when picked up, validated against the code at th - **Per-session dynamic tool schemas** — rejected: schemas are registry-global by design (one assembly vocabulary, the pinned-header snapshot contract), and re-registering per session would buy only what the execution-time strict-wider check already guarantees, at the cost of a per-session schema surface and header churn on every switch. - **Hard-match the retry to a prior denial** — rejected: command-string identity is fragile (quoting, `workdir`, env prefixes, a pipeline retried as its failing stage) — false-rejects honest retries or is trivially satisfied; the real boundary is the human seeing command + justification. Revisit only if `allow_always` grant storage ever needs machine-checkable scopes. - **A generic `env/state` facts map with an owner service** — rejected: approval and sandbox compose independently, so neither's state may drag in a third package; single-key folds are one `findLast` each, dissolving the owner service; no invariant spans the knobs, so atomic multi-key patches bought nothing. -- **Narrate via `agent/user-message` + a bus event** — rejected: it presupposes a turn-entry seam that does not exist (the real seam is `agent/prompt-submit`), and pre-step's position serves both the coalesced turn-entry notice and the mid-turn immediacy bound with one listener. +- **Narrate via `agent/user-message` + a bus event** — rejected: it presupposes a turn-entry seam that does not exist (the real seam is `agent/pre-step`), and pre-step's position serves both the coalesced turn-entry notice and the mid-turn immediacy bound with one listener. - **A standing prompt statement of the sandbox mode (+ a switch narrator)** — shipped first, then removed on live evidence: with `Bash commands run under the "read-only" file sandbox.` in every request, the model refused to ATTEMPT denied-then-escalatable work (five of twelve turns in the first manual session ended with zero tool calls), turning the sandbox into a soft lockout. The denial marker names the mode at the moment it matters and the escalation fields carry the recovery; the approval knob keeps its statement because an auto-rejection is behaviorally indistinguishable from a human "no". - **Track "last told" with its own bookkeeping events** — rejected: the `request/header` fold already records the exact prompt the model saw; parsing the closed candidate sentences back replaces a second bookkeeping stream — events are needed only where they ARE the store. - **Independent sandbox and approval selectors** — rejected: one deployment-defined permission preset keeps the two policy knobs coherent for UI clients that expose runtime switching. @@ -165,7 +165,7 @@ What shipped pins — the tiers in Testing hold each: - A resumed session's overrides apply with no catch-up state; a default changed while the process was down is narrated before the session's first new request, attributed to the operator. - Two concurrent sessions never see each other's state or notices. - Two concurrent project sessions in one Cordis context resolve independent workspace roots; bash and fs writes succeed inside the calling session's cwd and fail against its neighbor's cwd. -- `agent-loop` is untouched — everything rides `systemPrompt.section`, `SessionEventMap` merging, `agent.inject()`, `agent/step`, `agent/prompt-submit`, and capability-owned policy resolution. +- `agent-loop` is untouched — everything rides `systemPrompt.section`, `SessionEventMap` merging, `agent.inject()`, `agent/pre-step`, and capability-owned policy resolution. Costs and accepted limits: diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index dfa3349e4d..4e1d6b3419 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -149,7 +149,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **按会话动态工具 schema**:否决。schema 设计上是注册表全局的(一套 assembly 词汇、固定 header 快照契约),按会话重新注册只能买到执行时严格放宽检查已保证的东西,代价是按会话的 schema 表面和每次切换的 header 变动。 - **将重试硬匹配到先前的拒绝**:否决。命令字符串同一性脆弱(引号、`workdir`、env 前缀、作为失败阶段重试的管道)——要么误拒诚实的重试,要么被轻易满足;真正的边界是人看到命令 + 理由。仅在 `allow_always` 授权存储需要机器可检查的范围时才重新考虑。 - **通用 `env/state` facts map 加拥有者服务**:否决。approval 和沙箱独立组合,因此任何一方的状态都不应拖入第三个包;单键 fold 各自是一个 `findLast`,拥有者服务自然消解;没有跨旋钮的不变式,因此原子多键补丁无收益。 -- **通过 `agent/user-message` + 总线事件叙述**:否决。它预设了一个不存在的轮次入口 seam(真正的 seam 是 `agent/prompt-submit`),而步骤前检查点的位置使一个监听器能够同时服务合并的轮次入口通知和轮中即时性约束。 +- **通过 `agent/user-message` + 总线事件叙述**:否决。它预设了一个不存在的轮次入口 seam(真正的 seam 是 `agent/pre-step`),而步骤前检查点的位置使一个监听器能够同时服务合并的轮次入口通知和轮中即时性约束。 - **提示词中常驻声明沙箱模式(+ 切换叙述器)**:先交付后移除,基于实际证据:当每个请求中都有 `Bash commands run under the "read-only" file sandbox.` 时,模型拒绝尝试被拒绝后可升级的工作(首次手动会话中十二个轮次有五个以零工具调用结束),将沙箱变成了软锁定。拒绝标记在需要时命名模式,升级字段承载恢复路径;批准旋钮保留其声明,因为自动拒绝在行为上与人的「不」无法区分。 - **用专门的簿记事件追踪「上次告知」**:否决。`request/header` fold 已记录模型看到的确切提示词;将封闭的候选句子解析回来替代了第二条簿记流——事件仅在它们本身即为存储时才需要。 - **相互独立的沙箱与批准选择器**:否决。一个部署定义的权限 preset 让两个策略旋钮对暴露运行时切换的 UI 客户端保持一致。 @@ -165,7 +165,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - 恢复的会话的覆盖直接生效,无需追赶状态;进程停止期间变更的默认值在会话的首个新请求前被叙述,归因于运维人员。 - 两个并发会话永远看不到彼此的状态或通知。 - 同一个 Cordis 上下文中的两个并发项目会话解析各自独立的工作区根目录;bash 和 fs 写入在调用方会话的 cwd 内成功,对其相邻会话的 cwd 则失败。 -- `agent-loop` 未被触及——一切搭载 `systemPrompt.section`、`SessionEventMap` 合并、`agent.inject()`、`agent/step`、`agent/prompt-submit` 和由能力拥有的策略解析。 +- `agent-loop` 未被触及——一切搭载 `systemPrompt.section`、`SessionEventMap` 合并、`agent.inject()`、`agent/pre-step` 和由能力拥有的策略解析。 代价与已接受的限制: diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml index 910872881a..ccc67068be 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-16-durable-per-step-time-context.md: 4bc17b3c08707fcaa4f0f431e71ddbe567a03c9e -2026-07-16-durable-per-step-time-context.zh.md: 836c0f83fbe9d6120741a261cf25ce7d8c227bdf +2026-07-16-durable-per-step-time-context.md: e1a5c65894913ad93f46db8ae45e5ef5ead215f3 +2026-07-16-durable-per-step-time-context.zh.md: 140ac4dd9a6ce5fb466081140d86d3cc26c6622e diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md index 4bc17b3c08..e1a5c65894 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md @@ -12,9 +12,9 @@ A process-local refresh cache makes displayed time depend on state that cannot s ## Decision -`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. The `context/` group holds bounded request-context enrichments that define neither a tool nor a service, and shipped examples do not mount this plugin because its time-zone disclosure and token cost are deployment policy. It registers a prepended `agent/pre-step` listener and, when an injection is due, calls `agent.inject()` for a pre-step attempt whose signal is not already aborted. The injected `user/message` carries source `{ kind: 'plugin', plugin: 'time-context' }` and append surface metadata; a suppressed attempt appends nothing. +`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. The `context/` group holds bounded request-context enrichments that define neither a tool nor a service, and shipped examples do not mount this plugin because its time-zone disclosure and token cost are deployment policy. It registers a prepended `agent/pre-step` listener and, when a reading is due and the downstream decision enters, returns one additional `UserMessage`. The message carries source `{ kind: 'plugin', plugin: 'time-context' }`; a suppressed, rejected, or failed attempt appends nothing. -The listener records preparation context before a possible `step/start`. Its prepended registration runs before ordinary automatic compaction listeners, so pressure estimation and any resulting surface rewrite observe a newly appended reading. A later pre-step listener can cancel or fail the attempt before the step opens; the reading remains because the durable log is append-only and this plugin performs no rollback. +The listener samples before `step/start`, then settles its reading only in the final enter decision. AgentLoop records it after `step/start` and before request derivation. A downstream rejection or failure therefore prevents the reading from entering durable history. The optional `timeZone` config resolves the Node process's IANA zone once at plugin load when omitted; an explicit value is validated by `Intl.DateTimeFormat`. The timestamp includes the numeric UTC offset and resolved IANA zone. @@ -44,7 +44,7 @@ Their baseline is the durable event timestamp of the preceding time-context mess Each reading remains a normal surface node until compaction shadows it; positive interval scheduling never removes existing readings. A later request therefore sees the cumulative unshadowed readings that affected earlier preparation and steps, rather than a system-prompt value rewritten in place. -The plugin contributes nothing to system-prompt assembly. `request/header` contains no time-context text; request reconstruction obtains the complete durable surface prefix at each `step/start`. Readings and requests need not map one-to-one because a failed preparation can leave a reading while interval suppression can prepare a request without appending one. The plugin depends on the agent registry for its lifecycle listener and does not require the system-prompt service at runtime. +The plugin contributes nothing to system-prompt assembly. `request/header` contains no time-context text; request reconstruction obtains the complete durable surface prefix at each `step/start`. Readings and requests need not map one-to-one because interval suppression can enter a request without appending a reading, while rejection or failure appends neither. The plugin depends on the agent registry for its lifecycle listener and does not require the system-prompt service at runtime. ## Testing diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md index 836c0f83fb..140ac4dd9a 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md @@ -12,9 +12,9 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 分组容纳有界的请求上下文增强,这些增强既不定义工具也不定义服务;已交付示例不挂载此插件,因为时区披露与 token 成本属于部署策略。它注册一个前置的 `agent/pre-step` 监听器,并在需要注入时,为信号尚未取消的预步骤尝试调用 `agent.inject()`。注入的 `user/message` 携带来源 `{ kind: 'plugin', plugin: 'time-context' }` 和追加表层元数据;受间隔抑制的尝试不会追加任何内容。 +`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 分组容纳有界的请求上下文增强,这些增强既不定义工具也不定义服务;已交付示例不挂载此插件,因为时区披露与 token 成本属于部署策略。它注册一个前置的 `agent/pre-step` 监听器;需要读数且下游决策 enter 时,返回一条额外的 `UserMessage`。该消息携带来源 `{ kind: 'plugin', plugin: 'time-context' }`;受间隔抑制、reject 或失败的尝试不会追加任何内容。 -监听器在可能出现的 `step/start` 之前记录准备上下文。它采用前置注册,因此先于普通自动压缩监听器运行,使压力估算和由此产生的表层重写都能观察到新追加的读数。后续预步骤监听器可能在步骤开启前取消尝试或使其失败;持久日志仅追加,且本插件不执行回滚,因此该读数会保留下来。 +监听器在 `step/start` 之前采样,并只在最终 enter 决策中结算读数。AgentLoop 会在 `step/start` 之后、请求派生之前记录它。因此,下游 reject 或失败会阻止读数进入持久历史。 省略可选配置 `timeZone` 时,插件在加载时解析一次 Node 进程的 IANA 时区;显式值由 `Intl.DateTimeFormat` 校验。时间戳包含数字 UTC 偏移和解析后的 IANA 时区。 @@ -44,7 +44,7 @@ Elapsed since the preceding step context: . 每个读数都作为普通表层节点保留,直至压缩将其隐藏;正数间隔调度绝不会移除已有读数。因此,后续请求会看到影响先前准备过程和步骤且尚未被隐藏的累计读数,而不是一个被原地改写的系统提示词值。 -插件不向系统提示词组装贡献任何内容。`request/header` 不包含时间上下文文本;请求重建从每个 `step/start` 取得完整的持久表层前缀。读数与请求无需一一对应,因为失败的准备过程可能留下读数,而间隔抑制也可能使请求准备过程不追加读数。插件通过 agent 注册表使用生命周期监听器,运行时不需要系统提示词服务。 +插件不向系统提示词组装贡献任何内容。`request/header` 不包含时间上下文文本;请求重建从每个 `step/start` 取得完整的持久表层前缀。读数与请求无需一一对应,因为间隔抑制可以让请求进入步骤而不追加读数,reject 或失败则两者都不追加。插件通过 agent 注册表使用生命周期监听器,运行时不需要系统提示词服务。 ## 测试 diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml index ca0518d268..0bc6014aa1 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md -2026-07-19-same-session-goal-round-driver.md: 6f0059e8f4a09c979e3411ecde1b643f95819c26 -2026-07-19-same-session-goal-round-driver.zh.md: 871c8c13772eeab7b91ff2be6154ce798d4aced4 +2026-07-19-same-session-goal-round-driver.md: adca7ab2b33f9742fe341a4b6f5cf1517f29374b +2026-07-19-same-session-goal-round-driver.zh.md: 10fa9180d4b75c39691d621a59d971a05d7cf380 diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md index 6f0059e8f4..adca7ab2b3 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md @@ -22,13 +22,13 @@ The plugin has no configuration. `maxGoalRounds` is resolved and persisted by `d When an agent is idle, has no competing queued work, and its current goal is `active` plus `armed`, the driver checkpoints pending goal mutations and rechecks every predicate after the await. If `roundsStarted` already equals `maxGoalRounds`, it records `blocked` with code `round-limit`. Otherwise it reserves the exact identity `{ goalId, revision, round: roundsStarted + 1 }` and the complete rendered prompt before calling `Agent.followup()` with `GoalMessageSource`. The prompt JSON-quotes the objective so multiline or tag-like text remains an unambiguous data value inside the familiar frame. -The `agent/prompt-submit` waterfall is the admission fence. A positive goal source is allowed only when it exactly matches the driver's pending identity and content, the live goal still has that id and revision, activation remains armed, and the round is still the next number. The plugin checks once before delegating and again after downstream hooks return. This second check prevents an async hook from editing or pausing the goal while still admitting the old prompt. +The `agent/pre-step` waterfall is the entry fence. A positive goal source enters only when it exactly matches the driver's pending identity and content, the live goal still has that id and revision, activation remains armed, and the round is still the next number. The plugin checks once before delegating and again after downstream listeners return. This second check prevents an async listener from editing or pausing the goal while still entering the old prompt. -Only the resulting `user/message` is an admitted round and advances the goal fold. A stale reservation is discarded before a turn opens; the driver marks it stale and does not charge the round. A downstream policy rejection that is not caused by staleness blocks the goal rather than retrying around policy. +Only the resulting `user/message` is an entered round and advances the goal fold. A stale reservation is rejected before a turn opens; the driver marks it stale and does not charge the round. A downstream policy rejection that is not caused by staleness blocks the goal rather than retrying around policy. ### Human work and revision races -`agent/queued` distinguishes the driver's complete accepted record from every other prompt. Ordinary work already queued before a reservation prevents scheduling. Ordinary work queued while an automatic prompt is pending makes that reservation stale, so a mixed batch admits the human prompt but rejects the automatic one. Ordinary work arriving after the goal round was admitted remains queued for its own next turn; continuation is reconsidered only when the agent later becomes idle. +The reserved `MessageId` distinguishes the driver's complete record from every other prompt. Ordinary work already queued before a reservation prevents scheduling. Ordinary work queued while an automatic prompt is pending makes that reservation stale, so a mixed claimed batch rejects the automatic proposal. Ordinary work arriving after the goal round entered remains queued for its own next turn; continuation is reconsidered only when the agent later becomes idle. A goal mutation during a round advances its durable revision. Settlement of the older revision cannot overwrite that mutation. The driver discards the old attempt outcome, reads the new projection, and continues only if the new revision is still active and armed. This makes model-recorded completion, pause, block, and edit authoritative over the physical turn's later close reason. diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md index 871c8c1377..10fa9180d4 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md @@ -22,13 +22,13 @@ Status: implemented 当 agent 空闲、没有竞争中的排队工作,且当前目标为 `active` 加 `armed` 时,驱动器会先检查点持久化待处理的目标变更,并在等待之后重新校验所有条件。若 `roundsStarted` 已等于 `maxGoalRounds`,它会记录代码为 `round-limit` 的 `blocked`;否则,它会先预留精确身份 `{ goalId, revision, round: roundsStarted + 1 }` 和完整渲染提示词,再以 `GoalMessageSource` 调用 `Agent.followup()`。提示词用 JSON 引号编码目标描述,使多行或类似标签的文本在熟悉框架中仍是无歧义的数据值。 -`agent/prompt-submit` 瀑布是接纳栅栏。正数目标来源只有在完全匹配驱动器待处理的身份和内容、实时目标仍具有相同 id 与修订号、激活态仍为 armed,并且该回合仍是下一个编号时才会获准。插件在委托下游监听器前检查一次,在下游返回后再检查一次。第二次检查防止异步钩子编辑或暂停目标后,旧提示词仍被接纳。 +`agent/pre-step` 瀑布是进入栅栏。正数目标来源只有在完全匹配驱动器待处理的身份和内容、实时目标仍具有相同 id 与修订号、激活态仍为 armed,并且该回合仍是下一个编号时才会进入步骤。插件在委托下游监听器前检查一次,在下游返回后再检查一次。第二次检查防止异步监听器编辑或暂停目标后,旧提示词仍进入步骤。 -只有最终产生的 `user/message` 才是已接纳目标回合,并推进目标折叠。陈旧预留会在轮次打开前被丢弃;驱动器会把它标记为陈旧,不消耗回合数。若下游策略拒绝并非由陈旧状态导致,目标会进入 blocked,而不会绕过该策略自动重试。 +只有最终产生的 `user/message` 才是进入步骤的目标回合,并推进目标折叠。陈旧预留会在轮次打开前被 reject;驱动器会把它标记为陈旧,不消耗回合数。若下游策略拒绝并非由陈旧状态导致,目标会进入 blocked,而不会绕过该策略自动重试。 ### 人类工作与修订竞争 -`agent/queued` 会区分驱动器自己的完整已接受记录与其他所有提示词。预留之前已经排队的普通工作会阻止调度;自动提示词待处理时进入的普通工作会使该预留过期,因此混合批次只接纳人类提示词而拒绝自动提示词。目标回合已经接纳后到达的普通工作会保留在队列中,成为下一个独立轮次;只有 agent 再次空闲后才重新考虑继续执行。 +预留的 `MessageId` 会区分驱动器自己的完整记录与其他所有提示词。预留之前已经排队的普通工作会阻止调度;自动提示词待处理时进入的普通工作会使该预留过期,因此混合的已领取批次会 reject 自动提案。目标回合已经进入步骤后到达的普通工作会保留在队列中,成为下一个独立轮次;只有 agent 再次空闲后才重新考虑继续执行。 目标在回合内发生变更时会推进持久修订号。旧修订的结算不得覆盖该变更。驱动器会丢弃旧尝试的结果、读取新投影,并且只在新修订仍为 active 与 armed 时继续。因此,模型记录的完成、暂停、阻塞和编辑相对于物理轮次稍后的关闭原因具有最终权威。 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index 8266581269..67df7305c6 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-cross-session-references.md -2026-07-21-cross-session-references.md: fb6ee48a5f0c4bd89660ff24cfc523d744dacbe8 -2026-07-21-cross-session-references.zh.md: a35a44e9cf031a76947a5a662c62c27da72aba6c +2026-07-21-cross-session-references.md: 0f2f8a56eac3e58c67cf97efd94bc933ce323ecd +2026-07-21-cross-session-references.zh.md: b79fe3444f88e9d0255856fdc9bf9a5edb30b48f diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index fb6ee48a5f..0f2f8a56ea 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -26,9 +26,9 @@ One aggregated context is serialized as JSON beneath a fixed untrusted-backgroun ## Message ownership -TUI owns the snapshot/direct-message transaction without extending the generic inbox record. Outside the next-step acceptance window, it installs a one-shot outer `agent/prompt-submit` listener before `followup()`; an allowed decision receives the snapshot as `additionalContexts`, while a blocked or discarded prompt releases the listener and writes neither message. During prompt admission or an open turn, TUI calls `inject(snapshot)` then `steer(prompt)`, and AgentLoop stages both for the same safe boundary. If admission fails before that boundary, both remain staged for retry or a later admitted prompt; cancellation or disposal may discard them. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this generic delivery boundary. +TUI owns the snapshot/direct-message transaction without extending the generic inbox record. While the agent is idle, it installs a one-shot outer `agent/pre-step` listener before `followup()`; an enter decision receives the snapshot as another message, while rejection or an earlier ordinary discard releases the listener and writes neither message. While the agent is running, TUI calls `inject(snapshot)` then `steer(prompt)`, placing both in the next-step inbox for the same later claim. A rejecting or failed pre-step leaves that claimed pair removed; messages inserted after the claim remain pending. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this generic delivery boundary. -Reference preparation is not a new steering protocol and does not create a turn by itself. A `followup()` outside the next-step acceptance window dispatches prompt admission; steering inside the window bypasses it while retaining snapshot order through the shared outbox. +Reference preparation is not a new steering protocol and does not create a turn by itself. Idle delivery uses `followup()` and pre-step entry; running delivery uses the shared next-step inbox while retaining snapshot order. ## Host adapters @@ -45,8 +45,8 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b - **Wait for SQLite FTS5** — rejected because snapshot correctness requires exact id reads and canonical surface folding, not content search. FTS improves discovery only. - **Put mention syntax in agent delivery methods** — rejected because it would make the core protocol parse one host's presentation syntax and prevent typed non-text hosts from sharing the semantic layer. - **Implement references separately in each host** — rejected because projection, security warning, retention, and persistence would drift across hosts. -- **Attach context to `SendOptions` and the inbox record** — rejected because generic delivery would own a domain transaction through admission, steering, cancellation, and observation. A domain-specific admission wrapper and the existing next-step outbox preserve the required pairing without enlarging every message. -- **Bake the prefix host-side before `followup()`** — rejected because `agent/prompt-submit` must inspect and rewrite only the direct prompt. Keeping the snapshot as a separate sourced message preserves that boundary and lets TUI hide background bytes from the direct user bubble. +- **Attach context to `SendOptions` and the direct prompt's inbox record** — rejected because generic delivery would own a domain transaction through admission, steering, cancellation, and observation. A domain-specific admission wrapper and the existing next-step inbox preserve the required pairing without enlarging every direct prompt. +- **Bake the prefix host-side before `followup()`** — rejected because `agent/pre-step` must inspect and rewrite only the direct prompt. Keeping the snapshot as a separate sourced message preserves that boundary and lets TUI hide background bytes from the direct user bubble. - **Replay the raw source log or restore shadowed events** — rejected because compact defines the current model surface and may intentionally retire sensitive or expensive history. - **Resume or fork the source** — rejected because the feature supplies read-only background for one target message, not identity or lifecycle continuity. - **Inject at request time by rereading the source** — rejected because the reference would become nondeterministic, cancellation races could alter its bytes, and target replay would depend on external mutable state. diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index a35a44e9cf..b79fe3444f 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -26,9 +26,9 @@ TUI 用户需要把另一场对话中的相关工作带入一条新消息,但 ## 消息所有权 -TUI 负责快照/直接消息事务,不扩展通用收件箱记录。在 next-step 接受窗口之外,它会在调用 `followup()` 前安装一次性的外层 `agent/prompt-submit` 监听器;获准决策会把快照作为 `additionalContexts` 接收,而被阻止或丢弃的提示词会释放监听器,并且不写入任何消息。提示词准入期间或轮次打开时,TUI 会依次调用 `inject(snapshot)` 和 `steer(prompt)`,AgentLoop 则将两者暂存到同一个安全边界。如果准入在抵达该边界前失败,两者都会保留暂存状态,供重试或后续获准提示词使用;取消或资源释放可能丢弃它们。这一通用交付边界由[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定。 +TUI 负责快照/直接消息事务,不扩展通用收件箱记录。agent 空闲时,它会在调用 `followup()` 前安装一次性的外层 `agent/pre-step` 监听器;enter 决策会把快照作为另一条消息接收,而 reject 或更早的普通丢弃会释放监听器,并且不写入任何消息。agent 运行时,TUI 会依次调用 `inject(snapshot)` 和 `steer(prompt)`,把两者放入 next-step inbox,等待后续同一次领取。pre-step reject 或失败会让这对已领取消息保持删除;领取后插入的消息继续等待。这一通用交付边界由[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定。 -引用准备过程不是新的 steering 协议,本身也不会创建轮次。在 next-step 接受窗口之外调用 `followup()` 会分派提示词准入;窗口内的 steering 会绕过它,同时通过共享 outbox 保持快照顺序。 +引用准备过程不是新的 steering 协议,本身也不会创建轮次。空闲交付使用 `followup()` 和 pre-step 进入决策;运行期间的交付使用共享 next-step inbox,并保持快照顺序。 ## 宿主适配器 @@ -45,8 +45,8 @@ TUI 把会话候选与现有 `@` 文件提供方组合在一起。候选查询 - **等待 SQLite FTS5**:不予采纳,因为快照正确性依赖按准确 id 读取和规范表层折叠,而不是内容搜索。FTS 只改进候选发现。 - **把提及标记语法放入 agent 投递方法**:不予采纳,因为这会迫使核心协议解析某个宿主的展示语法,并阻止带类型的非文本宿主复用同一语义层。 - **在每个宿主中分别实现引用**:不予采纳,因为投影、安全警告、保留策略和持久化会在不同宿主之间逐渐偏离。 -- **把上下文附加到 `SendOptions` 和收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域专用的准入包装层和现有 next-step outbox 可以保持所需配对,而无需扩大每条消息。 -- **在调用 `followup()` 前由宿主合并前缀**:不予采纳,因为 `agent/prompt-submit` 必须只检查和改写直接提示词。将快照保留为独立的带来源消息,可以维持该边界,并让 TUI 从直接用户气泡中隐藏背景字节。 +- **把上下文附加到 `SendOptions` 和直接提示词的收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域专用的准入包装层和现有 next-step inbox 可以保持所需配对,而无需扩大每条直接提示词。 +- **在调用 `followup()` 前由宿主合并前缀**:不予采纳,因为 `agent/pre-step` 必须只检查和改写直接提示词。将快照保留为独立的带来源消息,可以维持该边界,并让 TUI 从直接用户气泡中隐藏背景字节。 - **回放原始源日志或恢复被遮蔽的事件**:不予采纳,因为压缩定义了当前模型表层,并且可能有意淘汰敏感或开销高昂的历史内容。 - **恢复或 fork 源会话**:不予采纳,因为本功能只为一条目标消息提供只读背景,不提供身份或生命周期连续性。 - **在请求时重新读取源会话并注入**:不予采纳,因为这会让引用变得不确定,取消竞态可能改变其字节内容,目标回放也会依赖可变的外部状态。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml index 4d686731fe..e02b43c646 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-web-permission-and-approval.md: cd402a039e55e7a24a038055dab5793aa0d08438 -2026-07-23-web-permission-and-approval.zh.md: ce4964789bc94a0962796bb2f5fbf1a94e8f5145 +2026-07-23-web-permission-and-approval.md: 240d4781062db76c64899abd1a1e12f56e4bc13f +2026-07-23-web-permission-and-approval.zh.md: c707fd07d064ba4b0ca001e69e56b62703fca346 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md index cd402a039e..240d478106 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md @@ -14,7 +14,7 @@ The web host composes the same sandboxed product path as the acp-agent compositi `createApiProxy` owns the approval pending registry. Its `approval/request` waterfall answerer reads the approval id from the session's just-appended `approval/asked` audit event (an ask with no audit event is a foreign channel and delegates), mints one stable rpcId per question, broadcasts the answerable `approval/requested` frame to every open mux stream, and replays still-pending frames verbatim on each mux open — the refresh-recovery baseline the contract already promised. `respond` routes by the echoed rpcId, validates `ApprovalResponsePayload` with the existing zod schema, cross-checks the payload's audit correlation against the routed entry, resolves the answerer, and broadcasts `approval/resolved`; the ask's abort signal withdraws the question as `cancelled`. -The permission select rides two new unary RPCs, `session.permissions` and `session.setPermission`, projecting `ctx.permission` into a protocol-owned `PermissionOption` DTO (the ACP bridge precedent: each protocol owns its presentation shape). A permission-less composition serves an empty select and clients hide the control. Idle switches are held last-write-wins in a proxy-side pending map and flushed on `agent/prompt-submit`, because knob events must stay turn-enclosed for durable replay; the shared `hasOpenTurn` fold moved to `dsh-session` and replaced the private copies in `dsh-user-approval`, the ACP bridge, and the proxy. +The permission select rides two new unary RPCs, `session.permissions` and `session.setPermission`, projecting `ctx.permission` into a protocol-owned `PermissionOption` DTO (the ACP bridge precedent: each protocol owns its presentation shape). A permission-less composition serves an empty select and clients hide the control. Idle switches are held last-write-wins in a proxy-side pending map and flushed on `agent/pre-step`, because knob events must stay turn-enclosed for durable replay; the shared `hasOpenTurn` fold moved to `dsh-session` and replaced the private copies in `dsh-user-approval`, the ACP bridge, and the proxy. Client-side, `Session` gained `permissions` and `setPermission`, and approval answering rides the runtime's `PendingWait` carrier. Per the designer draft, a pending approval takes over the composer: `ApprovalPanel` registers as a selector-routed entry of the conversation-declared `conversation.composer` chain (the ui-question pattern), replacing the InputBar with the justification headline, the paired command, and one-shot refuse/allow buttons; the `PendingApproval` domain face in ui-conversation's contract owns the `ApprovalResponsePayload` wire encoding over the carrier, and the broadcast resolved frame settles the wait and restores the composer. Question placeholders stay in the message flow. The sidebar mirrors the blocked state with an amber warning dot that outranks the running ring: the manager tracks per-session outstanding approvalIds (idempotent under mux-open replays, cleared per connection generation so the reopen replay is authoritative) rather than reading Session instances, so the dot lights for sessions never instantiated. The composer's bottom-row chip hosts the `PermissionSelect` control fed through the conversation inject face. The connection fixture mirrors the host: its resident approval is answerable once, and its permission select persists per session. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md index ce4964789b..c707fd07d0 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md @@ -14,7 +14,7 @@ Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-l `createApiProxy` 拥有审批 pending 注册表。它的 `approval/request` waterfall(瀑布式事件)应答者从会话刚追加的 `approval/asked` 审计事件中读取审批 id(没有审计事件的 ask 属于外部通道,予以委托),为每个问题 mint 一个稳定的 rpcId,向每个打开的 mux 流广播可应答的 `approval/requested` 帧,并在每次 mux 打开时原样重放仍处于 pending 的帧——这正是契约早已承诺的刷新恢复基线。`respond` 按回显的 rpcId 路由,用既有的 zod schema 校验 `ApprovalResponsePayload`,将载荷的审计关联与所路由的条目交叉核对,解析应答者,并广播 `approval/resolved`;ask 的中断信号会以 `cancelled` 撤回该问题。 -权限选择依托两个新的一元 RPC,`session.permissions` 与 `session.setPermission`,把 `ctx.permission` 投影为一个由协议拥有的 `PermissionOption` DTO(沿用 ACP bridge 的先例:每个协议拥有自己的呈现形状)。无权限的组合提供空的选择项,client 隐藏该控件。空闲期的切换以后写胜出(last-write-wins)的方式保存在 proxy 侧的 pending map 中,并在 `agent/prompt-submit` 时冲刷,因为旋钮事件必须保持轮次内闭合以支持持久回放;共享的 `hasOpenTurn` 折叠迁入 `dsh-session`,取代了 `dsh-user-approval`、ACP bridge 与 proxy 中各自的私有副本。 +权限选择依托两个新的一元 RPC,`session.permissions` 与 `session.setPermission`,把 `ctx.permission` 投影为一个由协议拥有的 `PermissionOption` DTO(沿用 ACP bridge 的先例:每个协议拥有自己的呈现形状)。无权限的组合提供空的选择项,client 隐藏该控件。空闲期的切换以后写胜出(last-write-wins)的方式保存在 proxy 侧的 pending map 中,并在 `agent/pre-step` 时冲刷,因为旋钮事件必须保持轮次内闭合以支持持久回放;共享的 `hasOpenTurn` 折叠迁入 `dsh-session`,取代了 `dsh-user-approval`、ACP bridge 与 proxy 中各自的私有副本。 在 client 侧,`Session` 新增了 `permissions` 与 `setPermission`,审批应答则依托运行时的 `PendingWait` 载体。按照设计师草稿,处于 pending 的审批会接管 composer:`ApprovalPanel` 注册为由会话声明的 `conversation.composer` 链中一个按选择器路由的条目(即 ui-question 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBar;ui-conversation 契约中的 `PendingApproval` 领域面拥有 `ApprovalResponsePayload` 在该载体上的协议编码(wire encoding),广播的 resolved 帧使该等待落定并恢复 composer。问题占位符仍留在消息流中。侧边栏用一枚琥珀色警示圆点同步呈现这一阻塞状态,且其优先级高于表示运行中的圆环:manager 跟踪每个会话尚未解决的 approvalId(对 mux 打开时的回放幂等,并按连接代次清除,以保证重开后的回放才是权威依据),而非读取 Session 实例,因此从未实例化过的会话也能点亮该圆点。composer 底行的 chip 经会话注入面挂载 `PermissionSelect` 控件。连接 fixture(测试前置数据)与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。 diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml index 9a3df405be..b0e6012183 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md -2026-07-27-skill-catalog-hot-refresh.md: 8e70fb10e7da4292325b72f3a0392bef2271738c -2026-07-27-skill-catalog-hot-refresh.zh.md: 9a6b6d944baa4a9cc4dddb5158fdcbac2b05f5db +2026-07-27-skill-catalog-hot-refresh.md: 43c2da75aeb0031dffb5f47d7e76805baa2920e5 +2026-07-27-skill-catalog-hot-refresh.zh.md: 04d39dd767e5438c57d3eb29523a8ff26592465c diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md index 8e70fb10e7..43c2da75ae 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md @@ -18,7 +18,7 @@ The skill capability separates catalog membership from instruction-body loading. A missing root is followed from its nearest existing ancestor one absent segment at a time with `fs.watchFile`, then handed to Chokidar once the real root exists. Before scanning, each discovery re-probes the retained root/ancestor mode. That independent probe re-establishes ancestor observation after deletion even when child removals invalidate and publish an authoritative empty catalog before, or without, a root `unlinkDir` event. Chokidar configuration exposes native-versus-polling mode, write stability, polling interval, symlink following, and project watcher capacity. First-party `write` and `edit` tool observations synchronously invalidate a relevant provider, so the next model step sees its own mutation without waiting for host delivery. Watch startup/runtime failures are logged and retried; discovery still returns readable candidates for direct loads but reports an incomplete observation. Teardown closes watchers and ignores late callbacks. -`@deepseek-ai/dsh-tool-skill` injects the first non-empty complete catalog as a durable sourced `user/message` on the first complete `agent/step` that observes one. At every `agent/step` it applies exact `skill` tool visibility, hashes the exact rendered text between the `` tags, and scans the read-only session events backwards without copying them for the newest recognizable visible catalog from this plugin. A changed digest appends a durable, complete replacement through `agent.inject()`, including an explicit empty catalog when all skills disappear. If no catalog remains visible but a recognizable one exists in historical events, compaction hid it and the next complete observation re-establishes the current catalog, including an empty tombstone. A current empty catalog with no historical publication emits nothing, while an incomplete snapshot preserves the last-good model view. The backward scan normally stops at the newest visible catalog; when compaction hides every catalog it pays an O(session-events) scan to recover that fact. +`@deepseek-ai/dsh-tool-skill` contributes the first non-empty complete catalog as a sourced `UserMessage` to the first entering `agent/pre-step` that observes one. At every pre-step it applies exact `skill` tool visibility, hashes the exact rendered text between the `` tags, and scans the read-only session events backwards without copying them for the newest recognizable visible catalog from this plugin. A changed digest contributes a complete replacement to an enter decision, including an explicit empty catalog when all skills disappear; rejection or listener failure records nothing. If no catalog remains visible but a recognizable one exists in historical events, compaction hid it and the next complete entering observation re-establishes the current catalog, including an empty tombstone. A current empty catalog with no historical publication emits nothing, while an incomplete snapshot preserves the last-good model view. The backward scan normally stops at the newest visible catalog; when compaction hides every catalog it pays an O(session-events) scan to recover that fact. The TUI consumes the same invalidation as presentation state, not session history. `skills/change` carries no diff; the TUI refetches `snapshot()` for the active session cwd, applies only the latest complete result, and retains the previous commands across incomplete observations. A complete empty result clears stale completions. Because pi-tui closes autocomplete when its provider is replaced, a catalog that arrives while the user is typing a slash-command name also triggers a suggestion-only re-query of the current draft. diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md index 9a6b6d944b..04d39dd767 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md @@ -18,7 +18,7 @@ skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snaps 系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。每次发现操作都会在扫描前重新探测所保留的根目录/祖先模式。即使子项移除在根目录 `unlinkDir` 事件之前就触发失效并发布权威空目录,或者该事件根本没有到达,这项独立探测也会在删除后重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会被记录并触发重试;发现过程仍会返回可读候选项供直接加载,但会报告不完整观测。资源销毁会关闭 watcher,并忽略延迟回调。 -`@deepseek-ai/dsh-tool-skill` 在 `agent/step` 首次观察到非空完整目录时,将该目录注入为一条持久且带来源的 `user/message`。每次 `agent/step`,它都会应用 `skill` 工具的精确可见性,对 `` 标签之间精确渲染的文本计算哈希,并从后向前扫描只读会话事件且不复制,以查找该插件发布的最新一条可识别且仍可见的目录。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。如果没有目录仍然可见,但历史事件中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录,包括空 tombstone。如果当前目录为空且历史上从未发布目录,则不发送任何内容;不完整快照则保留最后一次完整的模型视图。反向扫描通常在最新且仍可见的目录处停止;当压缩遮蔽所有目录时,它会以一次 O(session-events) 扫描的成本确认这一事实。 +`@deepseek-ai/dsh-tool-skill` 在首次观察到非空完整目录且返回 enter 的 `agent/pre-step` 中,贡献一条带来源的 `UserMessage`。每次 pre-step,它都会应用 `skill` 工具的精确可见性,对 `` 标签之间精确渲染的文本计算哈希,并从后向前扫描只读会话事件且不复制,以查找该插件发布的最新一条可识别且仍可见的目录。digest 变化时,插件会向 enter 决策贡献完整替换目录;所有 skill 消失时也包含显式空目录,reject 或监听器失败则不记录任何内容。如果没有目录仍然可见,但历史事件中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整且进入步骤的观察会重新建立当前目录,包括空 tombstone。如果当前目录为空且历史上从未发布目录,则不发送任何内容;不完整快照则保留最后一次完整的模型视图。反向扫描通常在最新且仍可见的目录处停止;当压缩遮蔽所有目录时,它会以一次 O(session-events) 扫描的成本确认这一事实。 TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills/change` 不携带 diff;TUI 会为活动会话的 cwd 重新获取 `snapshot()`,仅应用最新的完整结果,并在观测不完整时保留先前命令。完整的空结果会清除陈旧补全项。pi-tui 在其提供方被替换时会关闭自动补全,因此如果目录在用户输入斜杠命令名称期间到达,还会触发一次仅用于更新建议的当前草稿重查。 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml index b5fa2609a4..6ddc110ad6 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md -2026-07-27-tmux-location-context.md: bac5861f7f55c259de04d153115f164d90c415ad -2026-07-27-tmux-location-context.zh.md: 03cd722381c45604f7aae8f3d0a9f9fbb8b12bb5 +2026-07-27-tmux-location-context.md: ad39071ab8caf34375e37877ea4a7b5bc66f4613 +2026-07-27-tmux-location-context.zh.md: 39e2bde934e0318d4d768a2ef7aee6994cfba378 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md index bac5861f7f..ad39071ab8 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md @@ -14,7 +14,7 @@ tmux exposes this without a daemon: `$TMUX_PANE` names the process's pane, and ` `@deepseek-ai/dsh-tmux-context` is an opt-in function plugin in `packages/context/tmux-context/`, alongside the other bounded request-context enrichments that define neither a tool nor a service. Shipped examples do not mount it because tmux-location disclosure and its token cost are deployment policy. -**Pull on the first step of each turn, not a tmux push.** The plugin prepends an `agent/step` listener and acts only when `step === 1`. A pull model needs no background process, no hook installation in the user's tmux, and no teardown; it re-reads current state each turn so a moved, renamed, or re-laid-out pane is picked up naturally. Gating on the first step makes the reading per-turn: a location is stable within a turn, and re-querying every step would add cost without new information. A pane moved mid-turn is reflected on the next turn, which is the accepted tradeoff for the simpler design. +**Pull on the first step of each turn, not a tmux push.** The plugin prepends an `agent/pre-step` listener and acts only when `step === 1`. A pull model needs no background process, no hook installation in the user's tmux, and no teardown; it re-reads current state each turn so a moved, renamed, or re-laid-out pane is picked up naturally. Gating on the first step makes the reading per-turn: a location is stable within a turn, and re-querying every step would add cost without new information. A pane moved mid-turn is reflected on the next turn, which is the accepted tradeoff for the simpler design. **Read through the `ctx.bash` seam, never raw `child_process`.** The listener runs the tmux/`ps` read commands through `ctx.bash`, so the deployment's sandbox and policy apply and the plugin owns no subprocess code. Absent `ctx.bash`, absent tmux env, a wrong field count, or an empty pane id each make the attempt a no-op, matching how `workspace-context` no-ops without an `fs` provider. @@ -36,7 +36,7 @@ The turn preamble is the volatile first line; the two-line state block below it ### Durability and request reconstruction -Each reading is a normal surface node until compaction shadows it; the plugin contributes nothing to system-prompt assembly and `request/header` carries no tmux-context text. The reading records a preparation attempt, not a committed step: because the prepended listener runs first, its append may remain when a later `agent/step` listener cancels or fails the attempt, and the append-only log performs no rollback. +Each reading is a normal surface node until compaction shadows it; the plugin contributes nothing to system-prompt assembly and `request/header` carries no tmux-context text. The reading records a preparation attempt, not a committed step: because the prepended listener runs first, its append may remain when a later `agent/pre-step` listener cancels or fails the attempt, and the append-only log performs no rollback. The published `./invariant` companion registers no runtime check: a reading is a per-turn snapshot of external tmux state, so the session holds no cross-event relation to validate, and scheduling and format stay pinned by the package's pipeline tests. @@ -46,7 +46,7 @@ An agent booted inside tmux now receives its own session/window/pane location an ## Testing -Unit tests pin: first-step injection and source/surface metadata; the `$TMUX_PANE`-keyed command including its `#{pane_tty}`-vs-`ps -o tty=` guard; step-gating; change suppression across turns and re-injection on a moved pane; positive-interval suppression and threshold; every no-op path (no bash, nonzero exit, wrong field count, empty pane id, aborted signal, and a contained executor rejection from either `resolve()` or `run()` that warns instead of failing the turn); prepended ordering before ordinary `agent/step` listeners; resilience to a corrupt prior reading (non-text block, single-line text); and config rejection of negative and non-integer intervals. Per-file coverage is 100%. +Unit tests pin: first-step injection and source/surface metadata; the `$TMUX_PANE`-keyed command including its `#{pane_tty}`-vs-`ps -o tty=` guard; step-gating; change suppression across turns and re-injection on a moved pane; positive-interval suppression and threshold; every no-op path (no bash, nonzero exit, wrong field count, empty pane id, aborted signal, and a contained executor rejection from either `resolve()` or `run()` that warns instead of failing the turn); prepended ordering before ordinary `agent/pre-step` listeners; resilience to a corrupt prior reading (non-text block, single-line text); and config rejection of negative and non-integer intervals. Per-file coverage is 100%. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md index 03cd722381..39e2bde934 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md @@ -14,7 +14,7 @@ tmux 无需守护进程即可暴露这些信息:`$TMUX_PANE` 标识进程所 `@deepseek-ai/dsh-tmux-context` 是位于 `packages/context/tmux-context/` 的可选启用型函数插件,与其他既不定义工具也不定义服务的有界请求上下文增强并列。随附示例不挂载它,因为 tmux 位置披露及其 token 成本属于部署策略。 -**在每轮的第一个 step 拉取,而非 tmux 推送。** 插件前置注册一个 `agent/step` 监听器,仅在 `step === 1` 时动作。拉取模型无需后台进程、无需在用户的 tmux 中安装 hook、也无需清理;它每轮重新读取当前状态,因此被移动、改名或重新布局的 pane 都会被自然感知。以第一个 step 为门槛使读数按轮次生成:位置在一轮内是稳定的,逐步骤重复查询只会增加成本而不带来新信息。轮次中途移动的 pane 会在下一轮反映,这是换取更简单设计所接受的取舍。 +**在每轮的第一个 step 拉取,而非 tmux 推送。** 插件前置注册一个 `agent/pre-step` 监听器,仅在 `step === 1` 时动作。拉取模型无需后台进程、无需在用户的 tmux 中安装 hook、也无需清理;它每轮重新读取当前状态,因此被移动、改名或重新布局的 pane 都会被自然感知。以第一个 step 为门槛使读数按轮次生成:位置在一轮内是稳定的,逐步骤重复查询只会增加成本而不带来新信息。轮次中途移动的 pane 会在下一轮反映,这是换取更简单设计所接受的取舍。 **通过 `ctx.bash` seam 读取,绝不用裸 `child_process`。** 监听器通过 `ctx.bash` 运行 tmux/`ps` 只读命令,从而应用部署方的沙箱与策略,插件不拥有任何子进程代码。`ctx.bash` 缺失、tmux 环境缺失、字段数不符或 pane id 为空,都会使本次尝试成为空操作,与 `workspace-context` 在无 `fs` provider 时的空操作一致。 @@ -36,7 +36,7 @@ window active=<0|1>, pane active=<0|1>, layout ### 持久性与请求重建 -每条读数在被压缩遮蔽前都是普通表层节点;插件对系统提示装配毫无贡献,`request/header` 也不携带任何 tmux-context 文本。读数记录的是一次准备尝试,而非已提交的 step:由于前置监听器最先运行,当后续 `agent/step` 监听器取消或失败时其追加可能仍会保留,只追加的日志不做回滚。 +每条读数在被压缩遮蔽前都是普通表层节点;插件对系统提示装配毫无贡献,`request/header` 也不携带任何 tmux-context 文本。读数记录的是一次准备尝试,而非已提交的 step:由于前置监听器最先运行,当后续 `agent/pre-step` 监听器取消或失败时其追加可能仍会保留,只追加的日志不做回滚。 发布的 `./invariant` 伴生插件不注册任何运行时检查:读数是外部 tmux 状态的按轮快照,会话中不存在需要校验的跨事件关系,调度与格式由本包的管线测试固定。 @@ -46,7 +46,7 @@ window active=<0|1>, pane active=<0|1>, layout ## 测试 -单元测试固定了:首个 step 的注入及来源/表层元数据;以 `$TMUX_PANE` 为键的命令(含其 `#{pane_tty}` 与 `ps -o tty=` 的比对守卫);step 门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消,以及 `resolve()` 或 `run()` 抛出的执行器拒绝被兜住并记录警告而非使该轮失败);前置排序先于普通 `agent/step` 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。 +单元测试固定了:首个 step 的注入及来源/表层元数据;以 `$TMUX_PANE` 为键的命令(含其 `#{pane_tty}` 与 `ps -o tty=` 的比对守卫);step 门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消,以及 `resolve()` 或 `run()` 抛出的执行器拒绝被兜住并记录警告而非使该轮失败);前置排序先于普通 `agent/pre-step` 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index a0fd1ed4ec..f13055a2ce 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md -2026-07-17-one-send-one-turn.md: 3ae43f137206f25bdbc563875c17e24211f17d6b -2026-07-17-one-send-one-turn.zh.md: 097090073f44194a8f8d4578a8cc39ffc723eb79 +2026-07-17-one-send-one-turn.md: 8f851088d191915cca2637a8962d2368eaa919b2 +2026-07-17-one-send-one-turn.zh.md: 6abe748cf505cf4c006a35801575989d972e61ce diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index 3ae43f1372..8f851088d1 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -10,21 +10,21 @@ Suppose a caller submits message A and then message B with two `Agent.send()` ca That grouping depends on timing rather than caller intent. Calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though every caller used the same API. -This grouping changes behavior, not just the number of model calls. One ordinary turn owns prompt admission, `turn/start`, `turn/end`, and a durability checkpoint. If message B shares message A's turn, B can enter A's model request instead of first seeing A's closed result in the session log. Allowing one message while blocking another also requires a mixed state that no caller requested. +This grouping changes behavior, not just the number of model calls. One ordinary turn owns one claimed follow-up, `turn/start`, `turn/end`, and a durability checkpoint. If message B shares message A's turn, B can enter A's model request instead of first seeing A's closed result in the session log. Entering one follow-up while rejecting another also requires a mixed state that no caller requested. ## Decision The rule is simple: each successful `send()` creates one independent FIFO queue item. If that item runs, it is the only ordinary message in its turn. An item can be dropped before it starts, so the precise guarantee is at most one turn rather than exactly one; two sends are never silently combined. -Before enqueueing an item, `send()` checks the agent state and accepts an already identified, deeply frozen message. It mints an occurrence-local `InboxItemId` and publishes `agent/inbox/enqueue`; the pending occurrence remains addressable under the [addressable queue operations](../feature/2026-07-29-addressable-queue-operations.md) decision until the driver claims or discards it. +Before inserting a message, `send()` checks the agent state and accepts an already identified, deeply frozen value. The durable splice and `agent/inbox/inserted { message }` retain its `MessageId`; the pending message remains addressable through `Inbox.update()` and `Inbox.remove()` until the driver claims or discards it. The [claimed pre-step inbox decision](../architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md) owns the current lifecycle. If messages A and B are both processed, B's turn starts only after A records `turn/end` and A's durability checkpoint settles. B's request therefore sees whatever closed result A left in the same session log. A checkpoint error is reported, but settlement only releases this ordering barrier; it does not make a failed write durable. Broad `cancel()`, disposal, or a failure before `turn/start` can instead discard an unstarted item without opening an empty turn. -Prompt admission decides one message at a time before a turn opens. An allowed prompt becomes that turn's `user/message`; a blocked prompt is discarded without opening a turn or writing session history. Mixed-batch and all-blocked-batch branches do not exist. +At a turn boundary, the loop claims one follow-up after pending next-step input. `agent/pre-step` either rejects the proposal or returns the complete entering batch. A rejected follow-up remains removed without opening a turn or writing session history. Mixed ordinary follow-up branches do not exist. -The no-batching rule applies only to ordinary `send()`. Running `steer()` puts input in the outbox. While a turn remains open, the loop records that input at the next step boundary and steering makes another step the default. A failure before that boundary leaves the steering staged without waking the agent; a request-error retry action or a later prompt takes it, while cancellation or disposal can discard it. When the agent is idle, `steer()` delegates to `send()`, so it creates an independent ordinary queue item. +The no-batching rule applies only to ordinary follow-up input. `steer()` puts input in the next-step inbox and wakes the driver. During a turn, the loop can claim it at a later step boundary; while idle, the waking next-step batch starts a new turn. Input arriving after a batch was claimed waits for a later boundary, while cancellation or disposal can discard it. -`inject()` continues to add model-facing context without submitting an ordinary message. During a turn it waits in the outbox for a safe step boundary; while idle it appends a `user/message` directly, without opening a turn or running the model. Persistence owns the resulting eager drain. `cancel()` remains a whole-agent operation that can clear all unstarted ordinary and steering input and abort the current step. `status` and `whenIdle()` also describe the whole agent, not one message. Several one-message turns can share one `running` interval, so `running` does not prove that a turn is open. +`inject()` continues to add model-facing context without submitting ordinary input or waking the driver. It always waits in the next-step inbox for a later pre-step, including while idle; AgentLoop records it as `user/message` only when an enter decision returns it inside a turn. `cancel()` remains a whole-agent operation that can clear all unstarted ordinary input, steering, and injection and abort the current step. `status` and `whenIdle()` also describe the whole agent, not one message. ## Alternatives considered @@ -35,11 +35,11 @@ The no-batching rule applies only to ordinary `send()`. Running `steer()` puts i - Unit and property tests submit sends from the same stack, neighboring microtasks, different producers, and reentrant callbacks; every message gets its own FIFO-ordered turn. - A built-stdio test submits two lines and observes two model requests and two turn boundaries. - Delayed and rejected first-turn checkpoints keep the next turn waiting and prove that its request sees the preceding assistant result. -- Failure-path tests cover prompt veto, listener failure, broad cancellation, disposal, and failure before `turn/start`; rejected admission creates no turn, recorded turns stay balanced, messages do not merge, and surviving queued work still drains. -- Separate tests cover open-turn, failed-turn, and idle `steer()`, plus `inject()`, whole-agent status, and `whenIdle()`. +- Failure-path tests cover pre-step rejection, listener failure, broad cancellation, disposal, and failure before `turn/start`; rejection creates no turn, recorded turns stay balanced, messages do not merge, and surviving later work still drains. +- Separate tests cover open-turn, failed-turn, and idle `steer()`, pending `inject()`, whole-agent status, and `whenIdle()`. ## Consequences -Ordinary turn boundaries are predictable: messages A and B stay separate, and B runs only after A has closed and reached its checkpoint. Callers still do not receive a per-send completion handle; a pending occurrence can be removed through its live `InboxItemId`, broad cancellation can discard the entire unstarted tail, and status and quiescence remain agent-wide observations. +Ordinary turn boundaries are predictable: messages A and B stay separate, and B runs only after A has closed and reached its checkpoint. Callers still do not receive a per-send completion handle; a pending message can be removed through its `MessageId`, broad cancellation can discard the entire unstarted tail, and status and quiescence remain agent-wide observations. The trade-off is more model requests and more checkpoints. A busy queue can take longer to drain and can grow under sustained producers. Ordinary-send batching returns only through an explicit, measured contract. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index 097090073f..6abe748cf5 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -10,21 +10,21 @@ Status: implemented 这种分组取决于运行时机,而不是调用方的意图。因此,即使所有调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -这种分组改变的不只是模型调用次数。一个普通轮次包含提示词准入、`turn/start`、`turn/end` 和持久性检查点。如果消息 B 与消息 A 共用轮次,B 可能直接进入 A 的模型请求,而不是先看到 A 在会话日志中已经关闭的结果。若系统允许一条消息、阻止另一条消息,还需要引入调用方没有请求的混合状态。 +这种分组改变的不只是模型调用次数。一个普通轮次包含一条已领取 follow-up、`turn/start`、`turn/end` 和持久性检查点。如果消息 B 与消息 A 共用轮次,B 可能直接进入 A 的模型请求,而不是先看到 A 在会话日志中已经关闭的结果。若系统让一条 follow-up 进入、却拒绝另一条,还需要引入调用方没有请求的混合状态。 ## 决策 规则很简单:一次成功的 `send()` 创建一个独立的 FIFO 队列项。该队列项如果运行,就是所在轮次中唯一的普通消息。队列项可能在启动前被丢弃,因此精确保证是最多一个轮次,而不是必定一个轮次;两次 send 绝不会被悄悄合并。 -队列项入队之前,`send()` 会检查 agent 状态,并接受已有标识且经过深度冻结的消息。它会铸造一个仅属于本次入队的 `InboxItemId`,并发布 `agent/inbox/enqueue`;根据[可寻址队列操作](../feature/2026-07-29-addressable-queue-operations.md)决策,在驱动器认领或丢弃该项之前,这次待处理入队始终可以被寻址。 +消息插入之前,`send()` 会检查 agent 状态,并接受已有标识且经过深度冻结的值。持久 splice 与 `agent/inbox/inserted { message }` 会保留其 `MessageId`;在驱动器领取或丢弃该消息之前,可以通过 `Inbox.update()` 与 `Inbox.remove()` 寻址。当前生命周期由[已领取 pre-step inbox 决策](../architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md)规定。 如果消息 A、B 都进入处理,B 的轮次只能在 A 记录 `turn/end` 且 A 的持久性检查点处理结束后开始。因此,B 的请求能看到 A 在同一会话日志中留下的已关闭结果。检查点错误会照常报告,但处理结束只表示解除这道顺序屏障,不表示失败的写入已经持久化。广义 `cancel()`、dispose(资源释放)或 `turn/start` 之前的失败也可能丢弃尚未启动的队列项,而不打开一个空轮次。 -提示词准入会在轮次打开前,每次只决定一条消息。获准提示词成为该轮次的 `user/message`;被阻止的提示词会被丢弃,不打开轮次,也不写入会话历史。实现中不存在混合批次或全阻止批次分支。 +轮次边界上,循环会在待处理 next-step 输入之后领取一条 follow-up。`agent/pre-step` 要么拒绝提案,要么返回进入步骤的完整批次。被拒绝的 follow-up 保持已删除,不打开轮次,也不写入会话历史。实现中不存在混合普通 follow-up 分支。 -上述不合批规则只适用于普通 `send()`。agent 运行时,`steer()` 会把输入放入 outbox。只要当前轮次仍然打开,agent loop 就会在下一个步骤边界记录该输入,而 steering(中途引导)会默认让循环再执行一个步骤。在到达该边界前发生失败,会让 steering 保持暂存且不唤醒 agent;请求错误的重试动作或后续提示词会取走它,而取消或 dispose 可以将其丢弃。agent 空闲时,`steer()` 会委托给 `send()`,因此创建一个独立的普通队列项。 +上述不合批规则只适用于普通 follow-up 输入。`steer()` 会把输入放入 next-step inbox 并唤醒驱动器。在轮次期间,循环可以在后续步骤边界领取它;agent 空闲时,这个会唤醒的 next-step 批次会启动一个新轮次。批次被领取后才到达的输入会等待后续边界,而取消或 dispose 可以将其丢弃。 -`inject()` 继续添加面向模型的上下文,而不提交普通消息。轮次打开时,该上下文会留在 outbox 中,等待安全的步骤边界;agent 空闲时,系统会直接追加一条 `user/message`,既不打开轮次,也不运行模型。持久化层独立负责由此产生的即时排空。`cancel()` 仍是面向整个 agent 的操作,可以清空所有尚未启动的普通输入和 steering,并中止当前步骤。`status` 和 `whenIdle()` 描述的也是整个 agent,而不是某一条消息。多个单消息轮次可以共用一个 `running` 区间,因此 `running` 不表示轮次一定处于打开状态。 +`inject()` 继续添加面向模型的上下文,但不提交普通输入,也不唤醒驱动器。即使 agent 空闲,它也始终在 next-step inbox 中等待后续 pre-step;AgentLoop 只会在 enter 决策于轮次内返回它时,将其记录为 `user/message`。`cancel()` 仍是面向整个 agent 的操作,可以清空所有尚未启动的普通输入、steering 和注入,并中止当前步骤。`status` 和 `whenIdle()` 描述的也是整个 agent,而不是某一条消息。 ## 曾考虑的替代方案 @@ -35,11 +35,11 @@ Status: implemented - 单元测试和性质测试从同一调用栈、相邻微任务、不同生产方和重入回调提交 send;每条消息都会得到一个按 FIFO 排序的独立轮次。 - stdio 构建产物测试提交两行输入,并观察到两个模型请求和两个轮次边界。 - 延迟和拒绝第一个轮次的检查点,都能让下一个轮次保持等待,并证明其请求可以看到前一条助手结果。 -- 失败路径测试覆盖提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 之前的失败;准入拒绝不会创建轮次,已记录的轮次保持边界平衡,消息不会合并,仍需处理的排队工作也能继续清空。 -- 其他测试分别覆盖轮次打开时、轮次失败后和空闲时的 `steer()`,以及 `inject()`、面向整个 agent 的状态和 `whenIdle()`。 +- 失败路径测试覆盖 pre-step reject、监听器失败、广义取消、dispose 和 `turn/start` 之前的失败;reject 不会创建轮次,已记录的轮次保持边界平衡,消息不会合并,之后仍需处理的工作也能继续清空。 +- 其他测试分别覆盖轮次打开时、轮次失败后和空闲时的 `steer()`,以及待处理的 `inject()`、面向整个 agent 的状态和 `whenIdle()`。 ## 后果 -普通轮次的边界可预测:消息 A、B 始终分开,B 只能在 A 关闭并到达检查点后运行。调用方仍然拿不到逐次 send 的完成句柄;待处理项可通过其仍有效的 `InboxItemId` 移除,广义取消可以丢弃整个尚未启动的队尾,而状态与完全停稳仍是面向整个 agent 的观察。 +普通轮次的边界可预测:消息 A、B 始终分开,B 只能在 A 关闭并到达检查点后运行。调用方仍然拿不到逐次 send 的完成句柄;待处理消息可通过其 `MessageId` 移除,广义取消可以丢弃整个尚未启动的队尾,而状态与完全停稳仍是面向整个 agent 的观察。 代价是模型请求和检查点都会增加。繁忙队列可能需要更长时间才能清空;如果生产方持续提交消息,队列也可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 send 批处理。 diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml index dbd47ad90e..199ffa1327 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-plan-specific-collaboration-state.md: fb26d15238f0eb1b63fdccc7e48a6c49a44236cf -2026-07-22-plan-specific-collaboration-state.zh.md: 93186eebb263458bc99e7f7562d065fbf9e5d4bf +2026-07-22-plan-specific-collaboration-state.md: d139f3d861244c80acfb604d17172461bbf0cd57 +2026-07-22-plan-specific-collaboration-state.zh.md: 415e0b44a3f95d40c48ea3f4ac60da460a251f7e diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md index fb26d15238..d139f3d861 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md @@ -14,7 +14,7 @@ Plan mode also needs a durable stance, a reviewable plan artifact, an explicit h ## Decision -Plan mode owns a plan-specific product package: `@deepseek-ai/dsh-plan-mode` at `packages/plan/plan-mode/`. The durable fact is `plan/mode: { active: boolean }`, folded by `foldPlanMode(events)` with `false` as the empty-log value. `ctx.planMode.get(agent)` returns `{ active, pending? }`, and `set(agent, active)` records the boundary-applied selection. The existing prompt-submit, continuation, retry, append-failure, and disposal fences remain unchanged in meaning. +Plan mode owns a plan-specific product package: `@deepseek-ai/dsh-plan-mode` at `packages/plan/plan-mode/`. The durable fact is `plan/mode: { active: boolean }`, folded by `foldPlanMode(events)` with `false` as the empty-log value. `ctx.planMode.get(agent)` returns `{ active, pending? }`, and `set(agent, active)` records the boundary-applied selection. The pre-step, retry, append-failure, and disposal fences preserve the same state-transition ownership. Configuration is exactly `{ section: string }`. The package registers the fixed `plan:policy` section, `/plan [message]`, the exact `/plan off` direct-exit form, and `exit_plan_mode` itself. Bare `/plan` selects active; another non-empty argument selects it first and then sends the trimmed text through `agent.steer()`, making the text an ordinary logged user message in the affected step. `/plan off` selects inactive without model input and can cancel an entry that is still pending at the boundary. The exit tool remains registered while plan mode is inactive so the request tool catalog stays stable. @@ -24,7 +24,7 @@ Sandbox mode and approval policy remain separate enforcement axes. Plan mode nei ### Boundary and model contract -`plan/mode` is log-only and non-surface, so resume, fork, and compaction recover the state without a live mirror. A spawned agent begins inactive because there is no creation-time plan option. Pending user selections flush before the affected request assembly on prompt submission, ordinary continuation, or a request-recovery retry; a failed durable append leaves the intent pending for a later boundary. +`plan/mode` is log-only and non-surface, so resume, fork, and compaction recover the state without a live mirror. A spawned agent begins inactive because there is no creation-time plan option. Pending user selections flush before the affected request assembly at initial or continuation pre-step, or on a request-recovery retry; a failed durable append leaves the intent pending for a later boundary. The active state contributes the deployment's section at prompt order 50. Inactive state contributes no section, while `exit_plan_mode` remains registered in both states, so a transition changes the logged request header but not native tool schemas or the Code Mode SDK. A user-driven transition appends one plugin-sourced notice only when the last request header described the opposite state; a pre-first-request or net-zero selection adds none, and an approved tool exit relies on its tool result instead of a second notice. diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md index 93186eebb2..415e0b44a3 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md @@ -14,7 +14,7 @@ Plan mode 还需要持久协作状态、可评审的计划产物、显式人工 ## 决策 -Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` 的 `@deepseek-ai/dsh-plan-mode`。持久化事实为 `plan/mode: { active: boolean }`,由 `foldPlanMode(events)` 折叠,空日志值为 `false`。`ctx.planMode.get(agent)` 返回 `{ active, pending? }`,`set(agent, active)` 则记录在边界生效的选择。现有的提示词提交、continuation、重试、追加失败和 dispose(资源释放)栅栏在语义上保持不变。 +Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` 的 `@deepseek-ai/dsh-plan-mode`。持久化事实为 `plan/mode: { active: boolean }`,由 `foldPlanMode(events)` 折叠,空日志值为 `false`。`ctx.planMode.get(agent)` 返回 `{ active, pending? }`,`set(agent, active)` 则记录在边界生效的选择。pre-step、重试、追加失败和 dispose(资源释放)栅栏保留相同的状态转换归属。 配置严格为 `{ section: string }`。该包自行注册固定的 `plan:policy` 段、`/plan [message]`、精确匹配的 `/plan off` 主动退出形式,以及 `exit_plan_mode`。不带参数的 `/plan` 选择激活;其他非空参数则先选择激活,再通过 `agent.steer()` 发送去除首尾空白后的文本,使该文本在受影响的步骤中成为一条记录到日志的普通用户消息。`/plan off` 选择未激活,不产生模型输入,并可取消仍待在边界生效的进入选择。即使 plan mode 未激活,退出工具仍保持注册,以确保请求工具目录稳定。 @@ -24,7 +24,7 @@ Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` ### 边界与模型契约 -`plan/mode` 仅记录到日志且不进入表层,因此恢复、fork 和压缩都能恢复该状态,无需实时镜像。spawn 出的 agent 初始处于未激活状态,因为创建时没有 plan 选项。待生效的用户选择会在提示词提交、普通 continuation 或请求恢复重试时,于受影响的请求组装前写入日志;持久追加失败会让意图保持待定,留到后续边界处理。 +`plan/mode` 仅记录到日志且不进入表层,因此恢复、fork 和压缩都能恢复该状态,无需实时镜像。spawn 出的 agent 初始处于未激活状态,因为创建时没有 plan 选项。待生效的用户选择会在初始或续步 pre-step、或请求恢复重试时,于受影响的请求组装前写入日志;持久追加失败会让意图保持待定,留到后续边界处理。 激活状态在提示词顺序 50 处贡献部署提供的区段。未激活状态不贡献区段,但 `exit_plan_mode` 在两种状态下都保持注册,因此状态转换会改变已记录的请求头,却不改变原生工具 schema 或 Code Mode SDK。用户发起的转换只会在上一条请求头描述相反状态时追加一条来源为插件的通知;第一次请求前的选择或最终状态未变化的选择不会追加通知,经批准的工具退出则依赖其工具结果,不再追加第二条通知。 diff --git a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml index 376c763206..ecf29ad628 100644 --- a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md -2026-07-24-agent-loop-observable-state-machine.md: 2024662495d8396e946e7c43f010164cd9414b83 -2026-07-24-agent-loop-observable-state-machine.zh.md: dc83a310ff2d7945e6b7e79625224102f0f4871c +2026-07-24-agent-loop-observable-state-machine.md: b1773fdf7fd3bcbea9c6b9a4a0ce37c715bce4ca +2026-07-24-agent-loop-observable-state-machine.zh.md: cdbddff97f21fbe3fc921ef480cda2f293d1f758 diff --git a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md index 2024662495..b1773fdf7f 100644 --- a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md +++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md @@ -18,16 +18,16 @@ The public contract exposes four orthogonal state dimensions: - Registration lifetime is the `agent/created` to `agent/disposed` interval. Disposal is the terminal registry edge, not an `AgentStatus`. - Whole-agent activity is `AgentStatus = 'idle' | 'running'`. Consecutive turns may share one `running` interval. -- A FIFO-backed message progresses from `agent/inbox/enqueue` to exactly one `agent/inbox/dequeue` or `agent/inbox/discard`. Enqueue and dequeue correlate an occurrence by `MessageId` plus its queued-or-steering placement; same-placement repeats retire in FIFO order. The inbox events describe acceptance, claim, and removal rather than turn completion. -- A claimed turn passes through prompt admission and zero or more request steps. An automatic retry closes the failed turn and immediately opens another; `agent/settled` reports only the terminal turn in that chain and remains distinct from the whole-agent transition to `status === 'idle'`. +- A pending message emits `agent/inbox/inserted` when inserted, then either `agent/inbox/claimed` after an atomic pure-deletion claim or `agent/inbox/discarded` after an ordinary removal. `MessageId` correlates the exact message; durable splice coordinates retain placement and cancellation. Inbox events describe insertion, claim, and discard rather than turn completion. +- A claimed turn passes through pre-step entry and zero or more request steps. An automatic retry closes the failed turn and immediately opens another; `agent/settled` reports only the terminal turn in that chain and remains distinct from the whole-agent transition to `status === 'idle'`. -The loop keeps five machine extension events. `agent/prompt-submit` admits, rewrites, or blocks a claimed prompt. `agent/step` is the single awaited between-steps checkpoint and runs before every request is derived. `agent/request` is the waterfall for the frozen call configuration; the configuration comes only from `await next()`, not from a duplicate positional argument. `agent/request-error` serializes ownership of awaited model-request recovery. `agent/turn-stopping` runs when the turn otherwise has no work left; a listener that needs another step records real steering with `agent.steer()`, and the loop decides from that data after all listeners settle. +The loop keeps four machine extension events. `agent/pre-step` decides reject or enter for one exclusive claimed batch and runs before every proposed step. `agent/request` is the waterfall for the frozen call configuration; the configuration comes only from `await next()`, not from a duplicate positional argument. `agent/request-error` serializes ownership of awaited model-request recovery. `agent/turn-stopping` runs when the turn otherwise has no work left; a listener that needs another step records real steering with `agent.steer()`, and the loop decides from that data after all listeners settle. Continuation and termination are data rather than returned control enums. Tool calls and accepted steering require another step. A tool result carrying `concludesTurn` ends the tool loop at its step. The loop does not expose general `ContinuationDecision` or terminal-stop return channels. A model-request failure closes its step, then enters `agent/request-error` with the exact error, normalized `LlmFailure`, and live turn signal. A listener that owns recovery repairs state, returns `{ kind: 'retry' }`, and stops delegating. The loop closes the failed turn and opens one retry turn over that state without an intervening idle notification; retry is not another step inside the failed turn. `agent/settled` reports the terminal outcome, and `agent/error` remains the live error notification for consumers that report failures independently of turn settlement. The [retry-action decision](2026-07-27-request-error-retry-action.md) supersedes the command-shaped part of this design. -The event taxonomy removes `agent/pre-step`, `agent/post-step`, `agent/session-prefix`, `agent/step-result`, `agent/turn-continuation`, and `agent/turn-stop`. Durable turn and step boundaries remain session events. Model-facing additions use logged message channels, request configuration uses `agent/request`, response content is recorded as assembled, failed-request recovery uses the `agent/request-error` return action, and end-of-turn continuation uses `agent/turn-stopping` plus steering. +The event taxonomy removes the legacy prompt preparation/submission and serial step hooks together with `agent/post-step`, `agent/session-prefix`, `agent/step-result`, `agent/turn-continuation`, and `agent/turn-stop`. The single `agent/pre-step` waterfall owns claimed-message entry. Durable turn and step boundaries remain session events. Model-facing additions use logged message channels, request configuration uses `agent/request`, response content is recorded as assembled, failed-request recovery uses the `agent/request-error` return action, and end-of-turn continuation uses `agent/turn-stopping` plus steering. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md index dc83a310ff..cdbddff97f 100644 --- a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md @@ -18,16 +18,16 @@ agent 生命周期、agent 整体活动状态、收件箱条目的进度以及 - 注册生命周期是从 `agent/created` 到 `agent/disposed` 的区间。dispose(资源释放)是注册表的终止边界,而不是一种 `AgentStatus`。 - agent 整体活动状态为 `AgentStatus = 'idle' | 'running'`。连续多个轮次可以共用同一个 `running` 区间。 -- 由 FIFO 支撑的消息从 `agent/inbox/enqueue` 开始,最终必然进入 `agent/inbox/dequeue` 或 `agent/inbox/discard` 二者之一。enqueue 与 dequeue 通过 `MessageId` 加 queued 或 steering(中途引导)放置方式关联一次消息出现;放置方式相同的重复项按 FIFO 顺序结算。收件箱事件描述接受、领取和移除,而不是轮次完成。 -- 已领取的轮次经过提示词准入和零个或多个请求步骤。自动重试会关闭失败轮次并立即开启另一个轮次;`agent/settled` 只报告该重试链的终态轮次,且仍不同于 agent 整体转换到 `status === 'idle'`。 +- 待处理消息插入时会发出 `agent/inbox/inserted`,随后要么在原子纯删除领取后发出 `agent/inbox/claimed`,要么在普通删除后发出 `agent/inbox/discarded`。`MessageId` 关联确切消息;持久 splice 坐标保留 placement 与取消信息。inbox 事件描述插入、领取和丢弃,而不是轮次完成。 +- 已领取的轮次经过 pre-step 进入决策和零个或多个请求步骤。自动重试会关闭失败轮次并立即开启另一个轮次;`agent/settled` 只报告该重试链的终态轮次,且仍不同于 agent 整体转换到 `status === 'idle'`。 -循环保留五个状态机扩展事件。`agent/prompt-submit` 对已领取的提示词执行准入、改写或阻断。`agent/step` 是步骤之间唯一需要等待的检查点,在每次派生请求前运行。`agent/request` 是冻结调用配置所用的 waterfall;配置只能来自 `await next()`,不再通过重复的位置参数提供。`agent/request-error` 串行确定需要等待的模型请求恢复由谁负责。当轮次原本已经没有剩余工作时,`agent/turn-stopping` 运行;需要再执行一个步骤的监听器使用 `agent.steer()` 记录真实的 steering,循环在所有监听器完成后根据这份数据作出决定。 +循环保留四个状态机扩展事件。`agent/pre-step` 对独占的已领取批次执行 reject 或 enter 决策,并在每个拟议步骤前运行。`agent/request` 是冻结调用配置所用的 waterfall;配置只能来自 `await next()`,不再通过重复的位置参数提供。`agent/request-error` 串行确定需要等待的模型请求恢复由谁负责。当轮次原本已经没有剩余工作时,`agent/turn-stopping` 运行;需要再执行一个步骤的监听器使用 `agent.steer()` 记录真实的 steering,循环在所有监听器完成后根据这份数据作出决定。 是否继续和终止执行由数据表达,不再由返回的控制枚举表达。工具调用和已接受的 steering 要求再执行一个步骤。携带 `concludesTurn` 的工具结果会在其所属步骤终止工具循环。循环不再暴露通用的 `ContinuationDecision` 或终止停止返回通道。 模型请求失败会先关闭当前步骤,再携带准确错误、标准化 `LlmFailure` 和仍有效的轮次信号进入 `agent/request-error`。负责恢复的监听器修复状态、返回 `{ kind: 'retry' }`,并停止继续委托。循环会关闭失败轮次,并基于该状态开启一个重试轮次,中间不发布空闲通知;重试不是失败轮次内的另一个步骤。`agent/settled` 报告终态结果;对于需要脱离轮次结算单独报告失败的消费方,`agent/error` 仍作为实时错误通知保留。[重试动作决策](2026-07-27-request-error-retry-action.md)取代了本设计中命令形式的部分。 -事件分类体系移除了 `agent/pre-step`、`agent/post-step`、`agent/session-prefix`、`agent/step-result`、`agent/turn-continuation` 和 `agent/turn-stop`。持久的轮次与步骤边界仍由会话事件记录。面向模型的新增内容使用有日志记录的消息通道,请求配置使用 `agent/request`,响应内容按组装后的原样记录,失败请求恢复使用 `agent/request-error` 返回动作,轮次结束时是否继续则使用 `agent/turn-stopping` 加 steering 表达。 +事件分类体系移除了旧的提示词准备/提交与串行 step hook,以及 `agent/post-step`、`agent/session-prefix`、`agent/step-result`、`agent/turn-continuation` 和 `agent/turn-stop`。唯一的 `agent/pre-step` waterfall 负责已领取消息能否进入步骤。持久的轮次与步骤边界仍由会话事件记录。面向模型的新增内容使用有日志记录的消息通道,请求配置使用 `agent/request`,响应内容按组装后的原样记录,失败请求恢复使用 `agent/request-error` 返回动作,轮次结束时是否继续则使用 `agent/turn-stopping` 加 steering 表达。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml index 9031dc8eb0..0604e8e8bc 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md -2026-07-28-remove-synthetic-log-only-turns.md: af4da00f4fe1d7aebff845cd55053bb5b807c979 -2026-07-28-remove-synthetic-log-only-turns.zh.md: 9d72781d6b7cf396a830790d108f4ff25adc816a +2026-07-28-remove-synthetic-log-only-turns.md: eebb4bcfe42ca8aecac01a86e9d0c265ce57cfec +2026-07-28-remove-synthetic-log-only-turns.zh.md: a6278316ec4c946c51829d585f2854bd8aed4e13 diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md index af4da00f4f..eebb4bcfe4 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md @@ -8,7 +8,7 @@ English | [中文](2026-07-28-remove-synthetic-log-only-turns.zh.md) The session store exposed `appendOutOfBand()` so a plugin could publish a late log-only event while no agent turn was running. The method wrapped that event in `turn/start` and `turn/end`, then flushed it. This preserved the old rule that every durable event had to live inside a turn, but it made one identifier mean both a model-loop execution and a persistence-only update. -That rule was introduced when persistence recovery treated the last `turn/end` as the only committed boundary. The persistence scanners now preserve every valid contiguous event, and crash repair reacts only to an actually open turn. Idle context already uses the same capability by appending `user/message` between turns. Retaining synthetic turns for title updates therefore inflated turn counts, produced execution outcomes for work that never ran the model, and let a late metadata write consume the next turn number. +That rule was introduced when persistence recovery treated the last `turn/end` as the only committed boundary. The persistence scanners now preserve every valid contiguous event, and crash repair reacts only to an actually open turn. Retaining synthetic turns for title updates therefore inflated turn counts, produced execution outcomes for work that never ran the model, and let a late metadata write consume the next turn number. The generic seam also duplicated domain policy. Its marker map said which plugin events were eligible, while the title capability already owned cancellation, liveness, and stale-result rules. Replacing it with another generic or title-specific append wrapper would preserve the same type indirection for two literal event types. @@ -20,7 +20,7 @@ Core session invariants continue to enforce core-owned execution relations: turn The title service appends `session/title` directly after its existing service, revision, cancellation, and live-session checks. The bundled model helper appends its literal `session/title-llm-request` record before dispatch. Persistence observes both through the eager `session/event` path and drains them at ordinary checkpoints and lifecycle teardown; neither append forces a flush merely because it is between turns. A fallback, auxiliary request record, or accepted provider title may therefore appear after `turn/end` and before the next `turn/start`. -A session fork may end at any stable event position outside an open turn, not only at `turn/end`. This preserves standalone title and context records in a default fork while still rejecting a prefix cut through active execution. +A session fork may end at any stable event position outside an open turn, not only at `turn/end`. This preserves standalone title and other plugin-owned log-only records in a default fork while still rejecting a prefix cut through active execution. The historical [universal turn-enclosure decision](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md) remains useful only as the reason the synthetic mechanism was introduced. The [context-injection decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) established the current meaning: one turn represents one model-loop execution. diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md index 9d72781d6b..a6278316ec 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md @@ -8,7 +8,7 @@ Status: implemented 会话存储曾暴露 `appendOutOfBand()`,让插件可以在没有 agent(智能体)轮次运行时发布延迟到达的纯日志事件。该方法会用 `turn/start` 和 `turn/end` 包住事件,再将其刷写。这保留了「每个持久事件都必须位于轮次内」的旧规则,却让同一个标识符既表示模型循环执行,又表示仅持久化更新。 -引入该规则时,持久化恢复曾将最后一个 `turn/end` 视为唯一的已提交边界。如今,持久化扫描器会保留每个合法且连续的事件,崩溃修复也只处理确实处于开放状态的轮次。空闲上下文早已采用同一机制,在轮次之间追加 `user/message`。因此,为标题更新保留合成轮次会夸大轮次计数、为从未运行模型的工作产生执行结果,还会让延迟到达的元数据写入占用下一个轮次编号。 +引入该规则时,持久化恢复曾将最后一个 `turn/end` 视为唯一的已提交边界。如今,持久化扫描器会保留每个合法且连续的事件,崩溃修复也只处理确实处于开放状态的轮次。因此,为标题更新保留合成轮次会夸大轮次计数、为从未运行模型的工作产生执行结果,还会让延迟到达的元数据写入占用下一个轮次编号。 通用 seam 还重复了领域策略。它的标记映射说明哪些插件事件符合条件,而标题功能本就拥有取消、活跃性和陈旧结果处理规则。改用另一个通用或标题专属追加包装层,仍会为两个字面量事件类型保留同一层类型间接性。 @@ -20,7 +20,7 @@ Status: implemented 标题服务会在完成既有的服务状态、修订、取消和实时会话检查后,直接追加 `session/title`。随附模型辅助函数会在发起调用前追加其字面量 `session/title-llm-request` 记录。持久化通过尽快处理的 `session/event` 路径观察两者,并在常规检查点与生命周期 teardown 时排空;二者都不会仅因为位于轮次之间就强制 flush。因此,回退标题、辅助请求记录或已接受的提供方标题可以出现在 `turn/end` 之后、下一个 `turn/start` 之前。 -会话 fork 可以结束于开放轮次之外的任意稳定事件位置,而不限于 `turn/end`。这样,默认 fork 会保留独立标题和上下文记录,同时仍拒绝在活跃执行过程中截断前缀。 +会话 fork 可以结束于开放轮次之外的任意稳定事件位置,而不限于 `turn/end`。这样,默认 fork 会保留独立标题和其他插件所属的纯日志记录,同时仍拒绝在活跃执行过程中截断前缀。 历史上的[通用轮次封闭决策](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md)如今只适合用于解释为何曾引入合成机制。[上下文注入决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)确立了当前语义:一个轮次表示一次模型循环执行。 diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index b9d0d6cb33..ae1d830208 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -17,20 +17,22 @@ sequenceDiagram participant Session participant SDK as UI or SDK listener User->>Agent: followup(content) - Agent-->>SDK: agent/inbox/enqueue + Agent-->>SDK: agent/inbox/spliced + Agent-->>SDK: agent/inbox/inserted { message } Agent->>Driver: queued work wakes driver Driver-->>SDK: agent/status running - Note over Agent,Driver: next-step acceptance window opens - Driver->>Hooks: agent/prompt-submit waterfall - Hooks-->>Driver: authoritative allow, block, or add context - alt prompt blocked or admission failed - Driver-->>Driver: append context-only batch or keep steering boundary pending - else prompt allowed + Note over Agent,Driver: claim pending next-step input plus one queued prompt + Driver-->>SDK: agent/inbox/spliced pure deletion + Driver-->>SDK: agent/inbox/claimed { message, turn } per message + Driver->>Hooks: agent/pre-step waterfall + Hooks-->>Driver: authoritative reject or enter(messages) + alt proposed step rejected or pre-step failed + Driver-->>Driver: claimed batch stays removed; no turn opens + else enter proposed step Driver->>Session: turn/start - Driver->>Session: user/message - Driver->>Prompt: system-prompt/assemble waterfall - Driver-->>Driver: agent/step serial checkpoint Driver->>Session: step/start + Driver->>Session: user/message per entered message + Driver->>Prompt: system-prompt/assemble waterfall Driver->>LLM: agent/request waterfall, then llm/stream waterfall LLM-->>Driver: StreamChunk* Driver->>Session: assistant/chunk* @@ -53,11 +55,17 @@ sequenceDiagram Driver->>Session: tool/result end end - Driver->>Session: post-tool context and steering (no prompt-submit) Driver->>Session: step/end - Driver->>Hooks: agent/turn-stopping serial terminal checkpoint + opt natural stop and next-step inbox empty + Driver->>Hooks: agent/turn-stopping serial terminal checkpoint + end + opt next-step input is pending + Driver-->>Driver: claim pending next-step input + Driver-->>SDK: agent/inbox/claimed { message, turn } per message + Driver->>Hooks: agent/pre-step waterfall + Hooks-->>Driver: authoritative reject or enter(messages) + end end - Note over Agent,Driver: next-step acceptance window closes Driver->>Session: turn/end end Driver-->>SDK: agent/status idle @@ -65,9 +73,9 @@ sequenceDiagram The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set. -`dsh-compact-basic` uses `agent/step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. +`dsh-compact-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. -The returned `agent/prompt-submit` allow is authoritative; listeners wrapping `next()` preserve downstream content and additional contexts unless replacement is intentional. Steering bypasses that waterfall and joins at its durable checkpoint. +The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later boundary claims their next-step batch. SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors. diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 0504659c50..7e113833bc 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: e0cd8c8e95151734fc97e8b8ce775af918db82c9 -architecture.zh.md: 096278ceb39de2851d259b0a1c0dfab4751bd437 +architecture.md: 38f889b32a75d945876c1174b1673ff741960c10 +architecture.zh.md: 919b7cf4efb5f562b2d3c27335126ed101771211 diff --git a/docs/architecture.md b/docs/architecture.md index e0cd8c8e95..38f889b32a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,7 +59,7 @@ Events are the service extension API ([catalog](cordis-catalog/events.md), [prod ### Event Domains - **Session events** are durable log facts emitted through `session/event`. -- **Agent events** carry live `Agent` for status, prompt admission, request shaping, validation, and continuation. +- **Agent events** carry live `Agent` for inbox notifications, step entry, status, request shaping, validation, and continuation. - **Capability events** let owning seams attach policy and adapters without a loop import. ### Interception Semantics @@ -80,22 +80,19 @@ choose declarative identity and fresh/resume path -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: - wait for queued occurrence - claim (edit/remove end) -> emit agent/status(running) if starting an interval - open the next-step acceptance window - -> agent/prompt-submit - blocked or failed prompt -> close the window without opening a turn - append a context-only caller batch immediately - keep steering and context staged beside it pending for a later admitted turn - allowed prompt: + wait for waking inbox work + claim next-step input plus one next-turn message with a pure deletion splice + -> emit agent/inbox/claimed({ message, turn }) for each claimed message + -> emit agent/status(running) if starting an interval + -> agent/pre-step(messages, { turn, step, signal }) + reject or listener failure -> the claimed batch stays removed; stop the driver + enter: 'turn/start' - append prompt + additional contexts as separate 'user/message' events STEP loop: - agent/step - drain injected context and steering (steering bypasses prompt-submit) + 'step/start' + append the returned batch as separate 'user/message' events assemble system prompt and tool schemas snapshot the derived messages (the reconstruction boundary) - 'step/start' agent/request (config only) -> prepare adapter defaults/provenance under turn signal -> log request/header -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' @@ -104,22 +101,25 @@ forever: parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute model-order result -> ordered tools/post-execute -> 'tool/result' - drain accepted tool context and steering 'step/end' - continue for tools or steering unless a result concluded the turn - otherwise agent/turn-stopping -> drain -> continue only for steering - close the next-step acceptance window + tools owe another request or next-step inbox is nonempty + -> claim next-step messages + -> agent/pre-step (messages may be empty for a tool continuation) + -> append the entered batch and continue + otherwise agent/turn-stopping -> re-check the next-step inbox 'turn/end' -> agent/settled start the next waking queued message, or emit agent/status(idle) idle inject: - append 'user/message' - do not open a turn or run the model + queue non-waking next-step context + leave it pending until followup or steer wakes the driver ``` Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Admission-time and active-turn `inject()` stage for the next step; post-tool `additionalContexts` settles after results. Steering shares that staging boundary and requests another step. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly. +`inject()` always queues non-waking `next-step` context. A collecting or running driver claims it at the nearest later prompt boundary; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. Post-tool `additionalContexts` enter the same next-step inbox after their tool results. + +`agent/pre-step` receives the exclusive batch already removed from the inbox and finalizes whether the loop enters the proposed step. Its `PreStepContext` carries the exact upcoming turn and step plus the cancellation signal. `{ kind: 'reject' }` opens no step; `{ kind: 'enter', messages }` supplies the complete batch appended after `step/start`. A tool continuation with no newly claimed inbox input submits an empty batch so listeners can still contribute current-step context. Waterfall rewrites settle only in the final returned `messages` value. Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize one retry turn between failed-step and turn close; cancellation wins. Adapter-owned `retryPolicy` makes normal mode bounded; always mode delegates specialized recovery before retrying until success or cancellation ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). @@ -129,11 +129,11 @@ Final-adapter selection, dispatch, and iteration failures become terminal `finis Other failures use `agent/error`. Cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels asynchronous model-capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` emits its cause before queue clearing and abort; observers cannot veto; idle calls emit nothing. Durability records user or parent cancellation as `aborted`, teardown as `disposed`; teardown awaits quiescence. The cause affects reporting, not late result-context handling ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). -Turn and step events are turn-enclosed; idle injected `user/message` events may sit between turns. Reload closes an interrupted tail with a synthetic turn end. After close, only `agent/error` reports failures. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). +Turn and step events are turn-enclosed; the loop appends injected `user/message` events only from entered batches inside a turn. Reload closes an interrupted tail with a synthetic turn end. After close, only `agent/error` reports failures. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). ### Agent Handles -`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins drive agents with `followup()`, `steer()`, and `inject()`; `cancel()` stops work, while the awaited disposer owns teardown. `followup()` only queues an identified message: its `MessageId` follows durable inbox admission, not a prompt-specific output or turn ending. `agent/status` and `whenIdle()` describe whole-agent activity; only a caller that explicitly owns an activity interval may summarize that interval as a run result ([decision](../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md)). +`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins drive agents with `followup()`, `steer()`, and `inject()`; `cancel()` stops work, while the awaited disposer owns teardown. `followup()` only queues an identified message: its `MessageId` follows durable inbox insertion, claiming, and discard notifications, not a prompt-specific output or turn ending. `agent/status` and `whenIdle()` describe whole-agent activity; only a caller that explicitly owns an activity interval may summarize that interval as a run result ([decision](../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md)). ### Agent Scope @@ -165,7 +165,7 @@ A swappable capability usually has **interface / implementation / consumer** lay Exceptions combine LLM interface/consumer, filesystem policy, web registries, and named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). -`dsh-workspace-context` injects baseline at the first `agent/step` and appends `ctx.fs`-discovered changes through `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. +`dsh-workspace-context` uses the first `agent/pre-step` to prepend its baseline directly to the next-step inbox, replacing any still-pending predecessor, and appends `ctx.fs`-discovered changes through `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. Because claim precedes pre-step, that baseline may miss the current request. `dsh-paths` owns shared paths. ### Bundles And Apps @@ -186,7 +186,7 @@ New behavior attaches to a documented extension point; a loop change updates thi | Add filesystem access or policy | implement a `ctx.fs` provider or listen to `fs/*` policy events | | Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning | | Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` is the stop boundary | -| Add model-facing context | call `agent.inject()` to append a sourced `user/message` without a turn | +| Add model-facing context | call `agent.inject()` to queue sourced context for the next admitted request | | Add UI or editor integration | drive `ctx.agents`, render from `session/event`; terminal-only overlays use `ctx.tui` | | Add durable session state | extend `SessionEventMap`; render and replay from the log | | Add asynchronous session-title generation | register the sole `ctx.sessionTitle` provider | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 096278ceb3..919b7cf4ef 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -59,7 +59,7 @@ ### 事件域 - **会话事件**是通过 `session/event` 发出的持久日志事实。 -- **Agent 事件**携带活跃 `Agent`,用于状态、提示词准入、请求塑形、验证和续跑。 +- **Agent 事件**携带活跃 `Agent`,用于 inbox 通知、步骤进入、状态、请求塑形、验证和续跑。 - **功能事件**让所属服务边界无需导入循环即可附加策略和适配器。 ### 拦截语义 @@ -80,22 +80,19 @@ choose declarative identity and fresh/resume path -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: - wait for queued occurrence - claim (edit/remove end) -> emit agent/status(running) if starting an interval - open the next-step acceptance window - -> agent/prompt-submit - blocked or failed prompt -> close the window without opening a turn - append a context-only caller batch immediately - keep steering and context staged beside it pending for a later admitted turn - allowed prompt: + wait for waking inbox work + claim next-step input plus one next-turn message with a pure deletion splice + -> emit agent/inbox/claimed({ message, turn }) for each claimed message + -> emit agent/status(running) if starting an interval + -> agent/pre-step(messages, { turn, step, signal }) + reject or listener failure -> the claimed batch stays removed; stop the driver + enter: 'turn/start' - append prompt + additional contexts as separate 'user/message' events STEP loop: - agent/step - drain injected context and steering (steering bypasses prompt-submit) + 'step/start' + append the returned batch as separate 'user/message' events assemble system prompt and tool schemas snapshot the derived messages (the reconstruction boundary) - 'step/start' agent/request (config only) -> prepare adapter defaults/provenance under turn signal -> log request/header -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' @@ -104,22 +101,25 @@ forever: parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute model-order result -> ordered tools/post-execute -> 'tool/result' - drain accepted tool context and steering 'step/end' - continue for tools or steering unless a result concluded the turn - otherwise agent/turn-stopping -> drain -> continue only for steering - close the next-step acceptance window + tools owe another request or next-step inbox is nonempty + -> claim next-step messages + -> agent/pre-step (messages may be empty for a tool continuation) + -> append the entered batch and continue + otherwise agent/turn-stopping -> re-check the next-step inbox 'turn/end' -> agent/settled start the next waking queued message, or emit agent/status(idle) idle inject: - append 'user/message' - do not open a turn or run the model + queue non-waking next-step context + leave it pending until followup or steer wakes the driver ``` 每个步骤都会组装有序提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider`、`model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 -接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用这一暂存边界,并请求再执行一个步骤。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。 +`inject()` 始终将不会唤醒的上下文排入 `next-step`。collecting 或 running 驱动器会在最近的后续提示词边界领取它;idle 驱动器则会让它保持待处理,直至 `followup()` 或 `steer()` 唤醒驱动器。工具执行后的 `additionalContexts` 会在对应工具结果之后进入同一个 next-step inbox。 + +`agent/pre-step` 接收已经从 inbox 删除的独占批次,并最终决定循环是否进入拟议步骤。它的 `PreStepContext` 携带准确的 turn、step 与取消 signal。`{ kind: 'reject' }` 不会打开步骤;`{ kind: 'enter', messages }` 提供在 `step/start` 后追加的完整批次。当工具 continuation 没有新领取的 inbox 输入时,批次为空,listener 仍可为当前步骤贡献上下文。waterfall 的全部改写只在最终返回的 `messages` 中一次性结算。 裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以在失败步骤与轮次关闭之间授权一个重试轮次;取消优先。适配器拥有的 `retryPolicy` 使 normal mode 保持有界;always mode 先委托专门恢复,再持续重试直至成功或取消([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。 @@ -129,11 +129,11 @@ idle inject: 其他故障使用 `agent/error`。取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消异步模型能力准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 在清空队列和中止前发出原因;观察方不能否决;空闲调用不发事件。持久化层将用户或父级取消记录为 `aborted`,拆卸记录为 `disposed`;拆卸会等待完全停稳。原因只影响报告方式,不影响延迟完成的结果上下文处理([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 -轮次和步骤事件均位于轮次边界内;空闲时注入的 `user/message` 可以位于两个轮次之间。重新加载会用合成的轮次结束事件闭合中断尾部。关闭后仅由 `agent/error` 报告故障。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 +轮次和步骤事件均位于轮次边界内;loop 只会在轮次内从进入步骤的批次追加注入的 `user/message`。重新加载会用合成的轮次结束事件闭合中断尾部。关闭后仅由 `agent/error` 报告故障。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 ### Agent 句柄 -`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件用 `followup()`、`steer()` 和 `inject()` 驱动 agent;`cancel()` 停止工作,而拆卸由需等待完成的 disposer 负责。`followup()` 只会将一条带标识的消息排队:其 `MessageId` 跟踪持久 inbox 准入,而不标识某个提示词特有的输出或轮次结束。`agent/status` 与 `whenIdle()` 描述整个 agent 的活动;只有显式拥有某个活动区间的调用方才能将该区间概括为一次运行的结果([决策](../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md))。 +`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件用 `followup()`、`steer()` 和 `inject()` 驱动 agent;`cancel()` 停止工作,而拆卸由需等待完成的 disposer 负责。`followup()` 只会将一条带标识的消息排队:其 `MessageId` 跟踪持久 inbox 的插入、领取与丢弃通知,而不标识某个提示词特有的输出或轮次结束。`agent/status` 与 `whenIdle()` 描述整个 agent 的活动;只有显式拥有某个活动区间的调用方才能将该区间概括为一次运行的结果([决策](../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md))。 ### Agent 作用域 @@ -165,7 +165,7 @@ idle inject: 例外情况包括 LLM(大语言模型)合并接口和消费方、文件系统整合策略、web 使用注册表、skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀,或使用 ACP(Agent Client Protocol)子 agent([subagent.md](core-data-structures/subagent.md))。 -`dsh-workspace-context` 在第一次 `agent/step` 注入基线,并通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录隔离方式。`dsh-paths` 负责共享路径。 +`dsh-workspace-context` 在第一次 `agent/pre-step` 将基线直接 prepend 到 next-step inbox,并替换仍在等待的前序消息;它通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更。其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录隔离方式。由于领取发生在 pre-step 之前,该基线可能赶不上当前请求。`dsh-paths` 负责共享路径。 ### 组合包与应用 @@ -186,7 +186,7 @@ idle inject: | 添加文件系统访问或策略 | 实现 `ctx.fs` 提供方,或监听 `fs/*` 策略事件 | | 限制生成的进程 | 使用 `ctx.sandbox` 后端;消费方在生成前包装 argv | | 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stopping` 是停止边界 | -| 添加模型可见上下文 | 调用 `agent.inject()`,追加带来源的 `user/message`,但不创建轮次 | +| 添加模型可见上下文 | 调用 `agent.inject()`,将带来源的上下文排入下一次获准请求 | | 添加 UI 或编辑器集成 | 驱动 `ctx.agents`,从 `session/event` 渲染;仅终端浮层使用 `ctx.tui` | | 添加持久会话状态 | 扩展 `SessionEventMap`;从日志渲染和回放 | | 添加异步会话标题生成 | 注册唯一的 `ctx.sessionTitle` 提供方 | diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index e422ef17f3..7741d836ec 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: ebcd2eff53cbffa86328e1992baf409bce643cc2 -extension-cookbook.zh.md: 05bac113fceff54bde8e1f27d89484e20c2dd0da +extension-cookbook.md: fffb15ef1d51cd32aae8a6ee1a1620905263a277 +extension-cookbook.zh.md: e01814bdef1cbf9566a01f0ae42aa2c166d0fd93 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index ebcd2eff53..fffb15ef1d 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -102,12 +102,12 @@ Every product feature maps to a listener on a documented extension seam — the | Product feature | Plugin mechanism | |---|---| -| Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `tools/pre-execute`, `tools/post-execute`, and `agent/turn-stopping`; the waterfall seams return typed decisions, while `agent/turn-stopping` may steer another step; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | +| Hook system (user + project level) | listeners on `agent/session-start`, `agent/pre-step`, `agent/request`, `tools/pre-execute`, `tools/post-execute`, and `agent/turn-stopping`; the waterfall seams return typed decisions, while `agent/turn-stopping` may steer another step; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | | `/goal` | `ctx.goals` owns durable state, `dsh-goal-session` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control | | `/loop` | on the `turn/end` session event, `followup()` the next iteration; or force-continue | | Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and the structured-output execution's monotonic `concludeTurn()` marker | | Queued + steering messages | core `Agent.followup()` / `Agent.steer()` | -| Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | +| Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/pre-step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering and scope-local shadowing | | AGENTS.md (root) | a section provider reading the file | | AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 05bac113fc..e01814bdef 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -102,12 +102,12 @@ export function apply(ctx: Context) { | 产品功能 | 插件机制 | |---|---| -| 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`tools/pre-execute`、`tools/post-execute` 和 `agent/turn-stopping` 上的监听器;waterfall seam 返回类型化决策,`agent/turn-stopping` 则可通过 steering 触发下一步;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 | +| 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/pre-step`、`agent/request`、`tools/pre-execute`、`tools/post-execute` 和 `agent/turn-stopping` 上的监听器;waterfall seam 返回类型化决策,`agent/turn-stopping` 则可通过 steering 触发下一步;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 | | `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-session` 通过公共 `Agent` 调度同会话回合,独立的命令/工具生产方分别提供人类/模型控制 | | `/loop` | 在 `turn/end` 会话事件上 `followup()` 下一次迭代;或强制继续 | | 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和结构化输出执行的单调 `concludeTurn()` 标记来强制输出 | | 排队消息 + steering(中途引导) | 核心 `Agent.followup()` / `Agent.steer()` | -| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | +| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/pre-step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | | 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 | | AGENTS.md(根目录) | 一个读取该文件的 section provider | | AGENTS.md(子目录,按需触发)+ 文件变更通知 | 从 watcher / tool-result 监听器调用 `agent.inject()` | diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 53d02e5bca..f24e268b63 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:160`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:171`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,30 +75,85 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:270`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts) -### `agent/prompt-submit` — waterfall +### `agent/inbox/claimed` — emit -Allow, rewrite, or block one claimed inbox batch before it becomes model-visible or opens a turn. Call `next()` for the unchanged default. The signal controls only this admission attempt; listeners may cooperate with it but must not retain it for a later attempt or turn. +One message left the inbox for a turn. ```ts cordis-catalog /** - * Allow, rewrite, or block one claimed inbox batch before it becomes - * model-visible or opens a turn. Call `next()` for the unchanged default. The - * signal controls only this admission attempt; listeners may cooperate with - * it but must not retain it for a later attempt or turn. - * @param agent - the agent whose driver claimed the batch. - * @param messages - the claimed messages. - * @param signal - the current turn's explicit abort signal. + * One message left the inbox for a turn. + * @param agent - the agent whose inbox changed. + * @param event - the claimed message and owning turn. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/inbox/claimed'(this: Scoped, agent: Agent, event: { message: UserMessage; turn: number }): void +``` + +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) + +Source: [`packages/core/agent/src/types.ts:201`](../../packages/core/agent/src/types.ts) + +### `agent/inbox/discarded` — emit + +One message was discarded from the live inbox. + +```ts cordis-catalog +/** + * One message was discarded from the live inbox. + * @param agent - the agent whose inbox changed. + * @param event - the discarded message. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/inbox/discarded'(this: Scoped, agent: Agent, event: { message: UserMessage }): void +``` + +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) + +Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) + +### `agent/inbox/inserted` — emit + +One message entered the live inbox. + +```ts cordis-catalog +/** + * One message entered the live inbox. + * @param agent - the agent whose inbox changed. + * @param event - the inserted message. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/inbox/inserted'(this: Scoped, agent: Agent, event: { message: UserMessage }): void +``` + +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) + +Source: [`packages/core/agent/src/types.ts:193`](../../packages/core/agent/src/types.ts) + +### `agent/pre-step` — waterfall + +Reject a proposed step or replace the messages that enter it. Calling `next()` preserves the current messages. + +```ts cordis-catalog +/** + * Reject a proposed step or replace the messages that enter it. Calling + * `next()` preserves the current messages. + * @param agent - the agent proposing the step. + * @param messages - messages removed from the inbox for this step. + * @param context - proposed turn and step coordinates plus cancellation. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/prompt-submit'(this: Scoped, agent: Agent, messages: UserMessage[], signal: AbortSignal, next: () => Promise): Promise +'agent/pre-step'(this: Scoped, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) +Types: [Agent](../core-data-structures/core.md) · [PreStepDecision](../core-data-structures/core.md) · [PreStepContext](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:205`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:216`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -122,7 +177,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:231`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:242`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -145,7 +200,7 @@ Handle one failed model-request attempt before the loop retries or closes its st Types: [Agent](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [RequestFailureContext](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:254`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -167,7 +222,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:191`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -188,31 +243,7 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running` Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) - -### `agent/step` — serial - -Awaited serial checkpoint before EVERY request of a turn is built (the first as well as each post-tools continuation). The single "between steps" extension point: inject context, steer, or edit the session log here — the request's history derives from the log right after this settles. - -```ts cordis-catalog -/** - * Awaited serial checkpoint before EVERY request of a turn is built (the - * first as well as each post-tools continuation). The single "between - * steps" extension point: inject context, steer, or edit the session log - * here — the request's history derives from the log right after this settles. - * @param agent - the agent about to send a request. - * @param turn - the open turn number. - * @param step - the step number about to open. - * @param signal - the turn abort signal. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode serial - */ -'agent/step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void -``` - -Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) - -Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:190`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -238,7 +269,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:258`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:269`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index 89783b5d16..fe6ab858cf 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/compaction.md -compaction.md: 911b71d00fa4b42e9cdfa67f67d4e9b29e354a4a -compaction.zh.md: 643a116ff2edbbb53d300b4f5ff0ad36d401130b +compaction.md: 9e5b9f0da8be68d2205f022e7d9e59ebb7c1f6dc +compaction.zh.md: c5c70f2cf71b53e2caddca78986b371cf877ae64 diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 911b71d00f..9e5b9f0da8 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -62,7 +62,7 @@ type CompactionTrigger = 'pressure' | 'context-overflow' `CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, returning `null` when no safe work exists, and `compactRegion(...)` for an explicit inclusive surface range. Every backend marks its replacement `user/message` with the package-exported `COMPACT_CHECKPOINT_SOURCE`; consumers call `isCompactCheckpointSource()` instead of coupling checkpoint recognition to one backend. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. -Pressure compaction runs at serial `agent/step` before request derivation. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and returns a retry action only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. +Pressure compaction runs at serial `agent/pre-step` before request derivation. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and returns a retry action only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. The seam exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for those edge checks. Both validate current surface membership and reject missing seqs and orphan results; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics. diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index 643a116ff2..c5c70f2cf7 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -62,7 +62,7 @@ type CompactionTrigger = 'pressure' | 'context-overflow' `CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略;没有可安全执行的工作时返回 `null`。它还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。每个后端都使用包导出的 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用的 `user/message`;消费方调用 `isCompactCheckpointSource()`,而不是把检查点识别逻辑耦合到某一个后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 -压力压缩在串行 `agent/step` 中运行,先于请求推导。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才返回重试动作,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 +压力压缩在串行 `agent/pre-step` 中运行,先于请求推导。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才返回重试动作,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 该 seam 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于这些边缘检查。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与遗留结果;其缓存语义由[包契约](../../packages/compact/compact/README.md#tool-pairing-boundaries)规定。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index e3d045fa92..a6d9a70719 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: 1a0f6d0cc7604a4fbbc4ffb302a20d3895997bf4 -core.zh.md: 28d1b57f4844a532804906f92032ccadad503fd3 +core.md: d5fb4b1efb4a74d7a01ee09e4086863e981e2122 +core.zh.md: 2cb15e20a87160e0ff0bc375752282969d77124b diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 85bf93c42b..d5fb4b1efb 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -485,7 +485,7 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types type InboxTarget = 'next-turn' | 'next-step' ``` -Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. `Inbox.splice(target, start, deleteCount, inserted, outcome?)` uses standard splice coordinates, rejects duplicate pending message ids, and records the normalized mutation as durable `agent/inbox/spliced`. Replaying those events reconstructs both `nextTurn` and `nextStep`, including edits, insertion, admission, and cancellation. +Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. `Inbox.append`, `prepend`, `update`, `remove`, and `splice` record normalized durable `agent/inbox/spliced` mutations and reject duplicate pending ids. Ordinary removals are cancellations. `claim(target)` atomically removes the proposed step batch through pure deletion splices; the loop separately emits per-message claimed notifications. Whole-queue consumers such as UI projections reconstruct `nextTurn` and `nextStep` from the durable splices, while consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications. ```ts type-equiv /** Options for {@link Agent.cancel}. */ @@ -568,18 +568,18 @@ interface Agent { steer(message: UserMessage): void /** - * Append model-facing context without running the model. Admission or an - * open turn stages it at the next safe log position; outside that window it - * appends immediately without opening a turn. If admission closes without a - * turn, a context-only boundary appends immediately; context staged beside - * steering remains pending with it. + * Queue model-facing context for the next pre-step without waking the + * driver. Collecting and running drivers claim it at the nearest later + * step boundary; idle drivers leave it pending until follow-up or steering + * wakes them. It may miss a request whose pre-step already claimed its + * batch. Cancellation or disposal may discard pending context. * @param message - identified injected context and its producer provenance. */ inject(message: UserMessage): void } ``` -`AgentStatus` is `'idle' | 'running'`, and `SessionId` is branded. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. `followup()` returns no handle: its `MessageId` identifies durable inbox and admission facts, not a later assistant output or turn ending. `whenIdle()` observes the whole agent, so callers may call a receipt-to-idle interval a run only when they explicitly own that interval ([decision](../../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md)). `AgentOptions` is merge-extensible: core declares `provider?`, `model?`, and `maxTokens?` (dispatch requires provider and model after `agent/request`). When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission allows the exact-model adapter default to materialize before the request header, or otherwise leaves provider behavior unchanged. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. +`AgentStatus` is `'idle' | 'running'`, and `SessionId` is branded. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. `followup()` returns no handle: its `MessageId` identifies durable inbox insertion, claim, and discard facts, not a later assistant output or turn ending. `whenIdle()` observes the whole agent, so callers may call a receipt-to-idle interval a run only when they explicitly own that interval ([decision](../../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md)). `AgentOptions` is merge-extensible: core declares `provider?`, `model?`, and `maxTokens?` (dispatch requires provider and model after `agent/request`). When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission allows the exact-model adapter default to materialize before the request header, or otherwise leaves provider behavior unchanged. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. The cause is a TypeScript-enforced same-process input. An active cancellation holder copies it into the runtime-only `AbortSignal.reason`; a signal grants cooperating listeners no classification authority. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. @@ -591,22 +591,31 @@ The process-local initiator carried by `ctx.agents` is the exact `Agent` above, ## Interception decisions -Prompt decisions use the same identified `UserMessage` shape as durable user-role input. The allowed batch is authoritative and preserves every message's identity and provenance. Hook bridges map their native decision fields onto this typed result. +Pre-step decisions use the same identified `UserMessage` shape as durable user-role input. The entered batch is authoritative and preserves every message's identity and provenance. Hook bridges map their native decision fields onto this typed result. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/prompt-submit` returns a `PromptDecision` before a turn opens. Allow supplies the complete admitted batch; block rejects admission without creating turn events and must choose whether to discard the claimed messages. Messages not claimed by that admission remain pending: +`agent/pre-step` receives the exclusive claimed batch and the proposed step's coordinates and cancellation signal. The initial proposal runs before its turn opens; a tool continuation may submit an empty claimed batch between steps: ```ts type-equiv -/** - * Prompt interception result. An allowed batch replaces the submitted - * messages; a listener wrapping `next()` preserves that batch unless it - * intentionally replaces it. A blocked batch explicitly chooses whether to - * discard the claimed messages; unclaimed work remains pending. - */ -type PromptDecision = - | { kind: 'allow'; messages: UserMessage[] } - | { kind: 'block'; reason: string; discardClaimed: boolean } +/** Coordinates and cancellation for a proposed step. */ +interface PreStepContext { + /** Turn that will own the step. */ + readonly turn: number + /** Step proposed by the loop. */ + readonly step: number + /** Current turn cancellation signal. */ + readonly signal: AbortSignal +} +``` + +It returns a `PreStepDecision`. Reject opens no step. Enter supplies the complete message batch appended after `step/start`; claimed messages omitted by the final decision remain removed, while input inserted after the claim stays pending: + +```ts type-equiv +/** Whether and with which messages the loop enters a proposed step. */ +type PreStepDecision = + | { kind: 'reject' } + | { kind: 'enter'; messages: UserMessage[] } ``` `agent/request-error` runs after a failed model step closes and before its turn closes. Listeners can repair durable state or await policy work while the failed turn's signal is still live. A handling listener returns `{ kind: 'retry' }` without calling `next()`; the default `undefined` leaves the failure terminal. @@ -616,7 +625,7 @@ type PromptDecision = type RequestErrorAction = { kind: 'retry' } | undefined ``` -`agent/step` is the single serial boundary before request derivation. `agent/turn-stopping` runs when a turn has no tool or steering continuation, before one final steering drain. +`agent/pre-step` is the single serial boundary before request derivation. `agent/turn-stopping` runs when a turn has no tool or steering continuation, before one final steering drain. `agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it): diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 0453d62a0b..2cb15e20a8 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -493,7 +493,7 @@ type SessionEvent = { type InboxTarget = 'next-turn' | 'next-step' ``` -每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。`Inbox.splice(target, start, deleteCount, inserted, outcome?)` 使用标准 splice 坐标,拒绝重复的待处理消息 id,并将规范化变更记录为持久 `agent/inbox/spliced`。回放这些事件可以重建 `nextTurn` 和 `nextStep`,包括编辑、插入、准入与取消。 +每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。`Inbox.append`、`prepend`、`update`、`remove` 与 `splice` 会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。普通删除表示取消。`claim(target)` 通过纯删除 splice 原子移除拟进入步骤的批次;循环另行逐条发出 claimed 通知。UI 投影等整体队列消费方通过持久 splice 重建 `nextTurn` 与 `nextStep`,而跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 ```ts type-equiv /** Options for {@link Agent.cancel}. */ @@ -576,18 +576,18 @@ interface Agent { steer(message: UserMessage): void /** - * Append model-facing context without running the model. Admission or an - * open turn stages it at the next safe log position; outside that window it - * appends immediately without opening a turn. If admission closes without a - * turn, a context-only boundary appends immediately; context staged beside - * steering remains pending with it. + * Queue model-facing context for the next pre-step without waking the + * driver. Collecting and running drivers claim it at the nearest later + * step boundary; idle drivers leave it pending until follow-up or steering + * wakes them. It may miss a request whose pre-step already claimed its + * batch. Cancellation or disposal may discard pending context. * @param message - identified injected context and its producer provenance. */ inject(message: UserMessage): void } ``` -`AgentStatus` 为 `'idle' | 'running'`,`SessionId` 是品牌类型。dispose(资源释放)会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。`followup()` 不返回 handle:其 `MessageId` 标识持久 inbox 与准入事实,而不标识之后的助手输出或轮次结束。`whenIdle()` 观察整个 agent,因此只有显式拥有从回执到 idle 这一完整区间的调用方才能将其称为一次运行([决策](../../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md))。`AgentOptions` 可合并扩展:core 声明 `provider?`、`model?` 与 `maxTokens?`(在 `agent/request` 后,分发要求 provider 与 model 都存在)。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时,系统会在写入请求 header 前填入确切模型的适配器默认值,否则提供方行为保持不变。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 +`AgentStatus` 为 `'idle' | 'running'`,`SessionId` 是品牌类型。dispose(资源释放)会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。`followup()` 不返回 handle:其 `MessageId` 标识持久 inbox 的插入、领取与丢弃事实,而不标识之后的助手输出或轮次结束。`whenIdle()` 观察整个 agent,因此只有显式拥有从回执到 idle 这一完整区间的调用方才能将其称为一次运行([决策](../../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md))。`AgentOptions` 可合并扩展:core 声明 `provider?`、`model?` 与 `maxTokens?`(在 `agent/request` 后,分发要求 provider 与 model 都存在)。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时,系统会在写入请求 header 前填入确切模型的适配器默认值,否则提供方行为保持不变。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有者会将它复制到仅运行时的 `AbortSignal.reason`;signal 不授予协作监听器任何分类权限。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 @@ -599,22 +599,31 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有 ## 拦截决策 -提示词决策使用与持久 user-role 输入相同、带标识的 `UserMessage` 形状。获准批次具有权威性,并保留每条消息的标识与 provenance。钩子桥接层把其原生决策字段映射到这一类型化结果上。 +pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessage` 形状。进入步骤的批次具有权威性,并保留每条消息的标识与 provenance。钩子桥接层把其原生决策字段映射到这一类型化结果上。 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/prompt-submit` 在轮次打开前返回 `PromptDecision`。allow 提供完整的准入批次;block 拒绝准入且不产生任何轮次事件,并且必须选择是否丢弃已领取的消息。未被此次接纳领取的消息会继续保持待处理: +`agent/pre-step` 接收独占的已领取批次,以及拟进入步骤的坐标与取消 signal。首次提案发生在轮次打开前;工具 continuation 可以在步骤之间提交空的已领取批次: ```ts type-equiv -/** - * Prompt interception result. An allowed batch replaces the submitted - * messages; a listener wrapping `next()` preserves that batch unless it - * intentionally replaces it. A blocked batch explicitly chooses whether to - * discard the claimed messages; unclaimed work remains pending. - */ -type PromptDecision = - | { kind: 'allow'; messages: UserMessage[] } - | { kind: 'block'; reason: string; discardClaimed: boolean } +/** Coordinates and cancellation for a proposed step. */ +interface PreStepContext { + /** Turn that will own the step. */ + readonly turn: number + /** Step proposed by the loop. */ + readonly step: number + /** Current turn cancellation signal. */ + readonly signal: AbortSignal +} +``` + +它返回 `PreStepDecision`。reject 不会打开步骤。enter 提供在 `step/start` 后追加的完整消息批次;最终决策省略的已领取消息保持已删除,而领取后插入的输入仍留待后续处理: + +```ts type-equiv +/** Whether and with which messages the loop enters a proposed step. */ +type PreStepDecision = + | { kind: 'reject' } + | { kind: 'enter'; messages: UserMessage[] } ``` `agent/request-error` 在失败的模型步骤关闭之后、其轮次关闭之前运行。listener 可以在失败轮次的 signal 仍然存活时修复持久状态或 await 策略工作。处理该错误的 listener 返回 `{ kind: 'retry' }` 且不调用 `next()`;默认的 `undefined` 会让失败保持终态。 @@ -624,7 +633,7 @@ type PromptDecision = type RequestErrorAction = { kind: 'retry' } | undefined ``` -`agent/step` 是请求推导前唯一的串行边界。`agent/turn-stopping` 在轮次没有工具或 steering(中途引导)后续时运行,先于最后一次 steering 排空。 +`agent/pre-step` 是请求推导前唯一的串行边界。`agent/turn-stopping` 在轮次没有工具或 steering(中途引导)后续时运行,先于最后一次 steering 排空。 `agent/session-start` 携带 `SessionStartSource`(会话生命周期为何开始;桥接层据此匹配其 SessionStart): diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 68200cf698..23ac7d9f2f 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: 436ee6ea5530e5b4baf832ba446e09019f5c08d4 -session.zh.md: 7d2b762e9332299ea71c0e3b1902d1bc35d0db2a +session.md: 05c993755cc50fe9f1c6fe8836fc6a6fb4196ed2 +session.zh.md: ac7e59c8563a0319609557c7346919b6e1d78ad2 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 436ee6ea55..05c993755c 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -47,8 +47,7 @@ interface SessionEventMap { * (the queued message claimed for this turn), a synthetic `agent.inject()` * context (file-change notices, subdir AGENTS.md, skill content, cron * notifications, …), or an admitted goal continuation round. All three - * project their `content` verbatim; `source` tells them apart. An idle - * injection may append this event between turns without running the model. + * project their `content` verbatim; `source` tells them apart. */ 'user/message': UserMessage /** Raw stream chunk — token-level replay fidelity. */ @@ -485,7 +484,7 @@ An explicit `boundary` lets callers fork from any stable between-turn position, ## Why a turn ended: `TurnEndReasonMap` -`turn/start` has no trigger field. The admitted `user/message` batch records what entered the turn, `llm/retry` records request recovery, and idle injection opens no turn. `aborted.reason` retains the typed [`AgentCancelCause`](core.md#the-agent-handle) that stopped the driver. +`turn/start` has no trigger field. The entered `user/message` batch records what entered each step, `llm/retry` records request recovery, and idle injection remains pending until a waking delivery reaches a later pre-step. `aborted.reason` retains the typed [`AgentCancelCause`](core.md#the-agent-handle) that stopped the driver. ```ts type-equiv /** @@ -513,7 +512,7 @@ interface TurnEndReasonMap { ## Execution enclosure and standalone events -A turn encloses one model-loop execution, not the whole session log. Idle injected `user/message` events and plugin-owned log-only events may appear between `turn/end` and the next `turn/start`; they consume event seqs without incrementing turn numbers. Persistence eagerly records every contiguous accepted event, while crash repair closes only a genuinely open trailing turn. A producer that needs a durability barrier explicitly awaits `ctx.sessions.flush(session)`. +A turn encloses one model-loop execution, not the whole session log. AgentLoop records injected `user/message` events only from entering pre-step batches inside a turn; plugin-owned log-only events may still appear between `turn/end` and the next `turn/start`, consuming event seqs without incrementing turn numbers. Persistence eagerly records every contiguous accepted event, while crash repair closes only a genuinely open trailing turn. A producer that needs a durability barrier explicitly awaits `ctx.sessions.flush(session)`. The optional `dsh-session/invariant` companion enforces the relations owned by core: turn and step numbering, execution-event enclosure, and same-step tool call/result pairing. Merge-extensible event relations belong to the plugin that declares them, so core does not reject an unknown event merely because no turn is open. See [the standalone-event decision](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md). @@ -531,7 +530,7 @@ Activity ordering excludes the boundary through `lastActivityTime(events)`: pick A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history). Their owner decides whether they belong to an open execution turn or may stand between turns, and enforces any relation in its own invariant companion. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md). -The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record because neither has an open turn to enclose one; allowed context is instead evidenced by its sourced `user/message` (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). +The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` and the pre-turn `UserPromptSubmit` pre-step seam get no `hook/*` record because neither has an open turn to enclose one; entered context is instead evidenced by its sourced `user/message` (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). ## Durability contract diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 7d2b762e93..ac7e59c856 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -47,8 +47,7 @@ interface SessionEventMap { * (the queued message claimed for this turn), a synthetic `agent.inject()` * context (file-change notices, subdir AGENTS.md, skill content, cron * notifications, …), or an admitted goal continuation round. All three - * project their `content` verbatim; `source` tells them apart. An idle - * injection may append this event between turns without running the model. + * project their `content` verbatim; `source` tells them apart. */ 'user/message': UserMessage /** Raw stream chunk — token-level replay fidelity. */ @@ -489,7 +488,7 @@ declare class Session { ## 轮次的结束原因:`TurnEndReasonMap` -`turn/start` 没有 trigger 字段。已准入的 `user/message` 批次记录进入轮次的内容,`llm/retry` 记录请求恢复,idle 注入则不会打开轮次。`aborted.reason` 保留停止驱动器的类型化 [`AgentCancelCause`](core.md#the-agent-handle)。 +`turn/start` 没有 trigger 字段。返回 enter 的 pre-step 所产生的 `user/message` 批次记录进入轮次的内容,`llm/retry` 记录请求恢复,idle 注入则保持待处理,直到后续边界领取并让它进入步骤。`aborted.reason` 保留停止驱动器的类型化 [`AgentCancelCause`](core.md#the-agent-handle)。 ```ts type-equiv /** @@ -517,7 +516,7 @@ interface TurnEndReasonMap { ## 执行封闭与独立事件 -一个轮次包围一次模型循环执行,而不是整个会话日志。空闲注入的 `user/message` 事件和插件所属的纯日志事件可以出现在 `turn/end` 与下一个 `turn/start` 之间;它们占用事件 seq,但不递增轮次编号。持久化会尽快记录每个连续且已接受的事件,而崩溃修复只关闭确实仍处于开放状态的尾部轮次。需要持久性屏障的生产方会显式等待 `ctx.sessions.flush(session)`。 +一个轮次包围一次模型循环执行,而不是整个会话日志。AgentLoop 只会从轮次内返回 enter 的 pre-step 批次记录注入的 `user/message` 事件;插件所属的纯日志事件仍可出现在 `turn/end` 与下一个 `turn/start` 之间,占用事件 seq 但不递增轮次编号。持久化会尽快记录每个连续且已接受的事件,而崩溃修复只关闭确实仍处于开放状态的尾部轮次。需要持久性屏障的生产方会显式等待 `ctx.sessions.flush(session)`。 可选的 `dsh-session/invariant` 配套插件会强制核心拥有的关系:轮次与步骤编号、执行事件封闭,以及同一步骤内的工具调用/结果配对。可合并扩展事件的关系由声明它的插件拥有,因此核心不会仅因没有开放轮次就拒绝未知事件。见[独立事件决策](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)。 @@ -535,7 +534,7 @@ interface TurnEndReasonMap { 插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史)。事件所有方决定它们属于一个开放的执行轮次,还是可以独立位于轮次之间,并在自己的不变量配套插件中强制所需关系。完整的逐事件枚举(核心与插件贡献的,含 payload 与溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 -钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中间的钩子点(`PreToolUse`/`PostToolUse`/`Stop`)在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 与轮次开始前的 `UserPromptSubmit` 准入 seam 都不生成 `hook/*` 记录,因为两者都没有已打开的轮次可容纳该记录;被放行的上下文改由其带来源的 `user/message` 作为持久证据(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 +钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中间的钩子点(`PreToolUse`/`PostToolUse`/`Stop`)在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 与初始 follow-up 在轮次开始前运行的 `UserPromptSubmit` pre-step 都不生成 `hook/*` 记录,因为二者都没有已打开的轮次可容纳该记录;进入步骤的上下文改由其带来源的 `user/message` 作为持久证据(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 ## 持久性契约 diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index f5c134815e..c7cae9e471 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/skills.md -skills.md: d4b41845bea009444653739abad712e9ce3afb13 -skills.zh.md: 8d6793129080487836b2e2471b8659df5a402974 +skills.md: 16e6fb649f9db4d7be47468adf0bf8a00df428c3 +skills.zh.md: 2237e291b437043b25b3016c25d061bac51fa860 diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index d4b41845be..16e6fb649f 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -211,7 +211,7 @@ interface Config { ## Session catalog and tool contract -`dsh-tool-skill` injects the initial durable user-role `` at the first `agent/step` of a live session that observes a non-empty complete view. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Discovery forwards the step's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. +`dsh-tool-skill` injects the initial durable user-role `` at the first `agent/pre-step` of a live session that observes a non-empty complete view. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Discovery forwards the step's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. Before each later model step, the consumer applies exact tool visibility and digests the exact rendered entries between the `` tags from a complete snapshot. It derives the comparison baseline from the same entries in the newest recognizable visible catalog message sourced by the plugin. A changed digest appends a durable full replacement through `agent.inject()`; deleting every skill appends an explicit empty replacement. Incomplete snapshots preserve the last-good model view. If compaction hides every historical catalog message, the next complete snapshot re-establishes the current catalog; an empty view with no prior catalog emits nothing. These catalog messages are session history, not World State. diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 8d67931290..2237e291b4 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -211,7 +211,7 @@ interface Config { ## 会话目录与工具契约 -`dsh-tool-skill` 在存活会话中第一个观察到非空完整视图的 `agent/step` 注入初始的持久 user-role ``。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。发现通过 `SkillLookupOptions` 转发该步骤的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。 +`dsh-tool-skill` 在存活会话中第一个观察到非空完整视图的 `agent/pre-step` 注入初始的持久 user-role ``。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。发现通过 `SkillLookupOptions` 转发该步骤的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。 在后续每个模型步骤之前,消费方都会应用精确的工具可见性,并对完整快照中 `` 标签之间精确渲染的条目计算 digest。它以该插件所发布、最新一条可识别且仍可见的目录消息中的相同条目作为比较基线。digest 发生变化时,会通过 `agent.inject()` 追加一条持久的完整目录替换;删除所有 skill 时会追加一条显式的空替换。不完整快照会保留上一份可用模型视图。如果压缩(compaction)隐藏了所有历史目录消息,下一份完整快照会重新建立当前目录;如果视图为空且从未发布目录,则不发送任何内容。这些目录消息属于会话历史,而非 World State。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 01446eb362..f891f21cc4 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,16 +8,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:160`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:169`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:205`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:231`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`jsonrpc`](../packages/ui/jsonrpc), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:218`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:258`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:171`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:201`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`tui`](../packages/ui/tui) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:209`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`tui`](../packages/ui/tui) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:193`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | — | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:225`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:242`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:254`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:190`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`jsonrpc`](../packages/ui/jsonrpc), [`tui`](../packages/ui/tui) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:269`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index bd8d396573..97e6dd865c 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:261`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:329`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) ## Events @@ -101,7 +101,7 @@ Sources: [`packages/core/session/src/types.ts:261`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) ### `approval/*` @@ -175,7 +175,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:194`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -191,7 +191,7 @@ Source: [`packages/core/session/src/types.ts:194`](../packages/core/session/src/ Types: [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:202`](../packages/core/session/src/types.ts) ### `command/*` @@ -400,7 +400,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:51`](../packages/plan/plan-mode/s 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -453,7 +453,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:258`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -489,7 +489,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:43`](../packages 'steering/message': { turn: number; message: UserMessage } ``` -Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) ### `step/*` @@ -500,7 +500,7 @@ Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:183`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:185`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -509,7 +509,7 @@ Source: [`packages/core/session/src/types.ts:183`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:181`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:183`](../packages/core/session/src/types.ts) ### `todo/*` @@ -522,7 +522,7 @@ Source: [`packages/core/session/src/types.ts:181`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:229`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:230`](../packages/core/session/src/types.ts) ### `tool/*` @@ -539,7 +539,7 @@ Source: [`packages/core/session/src/types.ts:229`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:208`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -612,7 +612,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/c } ``` -Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:220`](../packages/core/session/src/types.ts) ### `turn/*` @@ -630,7 +630,7 @@ Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:179`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:181`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -643,7 +643,7 @@ Source: [`packages/core/session/src/types.ts:179`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:172`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:174`](../packages/core/session/src/types.ts) ### `user/*` @@ -655,10 +655,9 @@ Source: [`packages/core/session/src/types.ts:172`](../packages/core/session/src/ * (the queued message claimed for this turn), a synthetic `agent.inject()` * context (file-change notices, subdir AGENTS.md, skill content, cron * notifications, …), or an admitted goal continuation round. All three - * project their `content` verbatim; `source` tells them apart. An idle - * injection may append this event between turns without running the model. + * project their `content` verbatim; `source` tells them apart. */ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) diff --git a/docs/user/develop/framework/events.i18n.yaml b/docs/user/develop/framework/events.i18n.yaml index b20764a24d..0adc8a70b6 100644 --- a/docs/user/develop/framework/events.i18n.yaml +++ b/docs/user/develop/framework/events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -events.md: 5cd5d22f854d0b4e271e892cbdb1ccebe687ae49 -events.zh.md: 5fd4d5de53897e32523ab478626965ad7c9602ba +events.md: fcbdb5f39bf2078032affbc6469f7eecc795d3ba +events.zh.md: 8b96f27fd6930ca9a481be60f93bcd834901d5dc diff --git a/docs/user/develop/framework/events.md b/docs/user/develop/framework/events.md index 5cd5d22f85..fcbdb5f39b 100644 --- a/docs/user/develop/framework/events.md +++ b/docs/user/develop/framework/events.md @@ -101,7 +101,7 @@ declare module 'cordis' { ## Cordis events and session records -Harness Cordis events use `namespace/action` names, including `agent/step`, `agent/request`, `agent/request-error`, `tools/result`, and `session/event`. The generated [event catalog](../../../cordis-catalog/events.md) records complete signatures and modes. +Harness Cordis events use `namespace/action` names, including `agent/pre-step`, `agent/request`, `agent/request-error`, `tools/result`, and `session/event`. The generated [event catalog](../../../cordis-catalog/events.md) records complete signatures and modes. `turn/*`, `step/*`, `tool/call`, `tool/result`, and `compact/*` are durable session-event types, not same-named Cordis events. To observe them, listen to `session/event` and inspect `event.type`. diff --git a/docs/user/develop/framework/events.zh.md b/docs/user/develop/framework/events.zh.md index 5fd4d5de53..8b96f27fd6 100644 --- a/docs/user/develop/framework/events.zh.md +++ b/docs/user/develop/framework/events.zh.md @@ -101,7 +101,7 @@ declare module 'cordis' { ## Cordis 事件与会话记录 -Harness 的 Cordis 事件遵循 `namespace/action` 命名,例如 `agent/step`、`agent/request`、`agent/request-error`、`tools/result` 和 `session/event`。完整签名与触发模式见[Events 目录](../../../cordis-catalog/events.md)。 +Harness 的 Cordis 事件遵循 `namespace/action` 命名,例如 `agent/pre-step`、`agent/request`、`agent/request-error`、`tools/result` 和 `session/event`。完整签名与触发模式见[Events 目录](../../../cordis-catalog/events.md)。 `turn/*`、`step/*`、`tool/call`、`tool/result` 和 `compact/*` 是持久化的会话事件类型,不是同名 Cordis 事件。需要观察它们时,监听 `session/event` 并检查 `event.type`。 diff --git a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts index cb0e072d2e..de64e4599b 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts +++ b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts @@ -7,11 +7,13 @@ export const name = 'seed-goal' export const inject = ['goals'] export function apply(ctx: Context): void { - ctx.on('agent/step', (agent) => { - if (ctx.goals.get(agent) !== undefined) return - ctx.goals.create(agent, { - objective: 'Prove the composed goal survives in the session log', - maxGoalRounds: 7, - }) + ctx.on('agent/pre-step', (agent, _messages, _context, next) => { + if (ctx.goals.get(agent) === undefined) { + ctx.goals.create(agent, { + objective: 'Prove the composed goal survives in the session log', + maxGoalRounds: 7, + }) + } + return next() }) } diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index 9aa8acb76e..70067eee05 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -49,7 +49,7 @@ describe('ACP prompt lifecycle', () => { it('settles after an ordinary plugin failure', async () => { harness = await makeBridgeHarness({ script: [textResponse('must not run')] }) - harness.ctx.on('agent/step', () => { throw new Error('plugin pre-step failed') }) + harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') }) const sessionId = await newSession(harness) await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) .resolves.toEqual({ stopReason: 'end_turn' }) @@ -112,10 +112,10 @@ describe('ACP prompt lifecycle', () => { await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) }) - it('correlates a prompt whose admitted history is replaced', async () => { + it('correlates a prompt whose step history is replaced', async () => { harness = await makeBridgeHarness({ script: [textResponse('rewritten answer')] }) - harness.ctx.on('agent/prompt-submit', async () => ({ - kind: 'allow', + harness.ctx.on('agent/pre-step', async () => ({ + kind: 'enter', messages: [createUserMessage({ content: [{ type: 'text', text: 'rewritten prompt' }], source: { kind: 'plugin', plugin: 'test' }, @@ -212,39 +212,21 @@ describe('ACP prompt lifecycle', () => { expect(offered).toBe(1) }) - it('an admission-blocked prompt settles instead of hanging', async () => { + it('a pre-step-rejected prompt settles instead of hanging', async () => { harness = await makeBridgeHarness({ script: [] }) - harness.ctx.on('agent/prompt-submit', async () => ({ - kind: 'block' as const, - reason: 'policy said no', - discardClaimed: true, + harness.ctx.on('agent/pre-step', async () => ({ + kind: 'reject' as const, })) const sessionId = await newSession(harness) await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) .resolves.toEqual({ stopReason: 'end_turn' }) - // The blocked prompt opened no turn and streamed nothing. + // The rejected prompt opened no turn and streamed nothing. expect(messageText(harness)).toBe('') }) - it('settles a turnless prompt retained by its admission policy', async () => { + it('settles a prompt when pre-step fails before opening a turn', async () => { harness = await makeBridgeHarness({ script: [] }) - harness.ctx.on('agent/prompt-submit', async () => ({ - kind: 'block' as const, - reason: 'defer forever', - discardClaimed: false, - })) - const sessionId = await newSession(harness) - const agent = harness.ctx.agents.get(SessionId(sessionId))! - - await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) - .resolves.toEqual({ stopReason: 'end_turn' }) - expect(agent.status).toBe('idle') - expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false) - }) - - it('settles a prompt when admission fails before opening a turn', async () => { - harness = await makeBridgeHarness({ script: [] }) - harness.ctx.on('agent/prompt-submit', async () => { throw new Error('admission exploded') }) + harness.ctx.on('agent/pre-step', async () => { throw new Error('pre-step exploded') }) const sessionId = await newSession(harness) await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index ec0f3dc503..148117b9c8 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -413,7 +413,7 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi * Fixture parallel of the plan unit's double-event fold: `command/run` * records named `plan` set the wanted target (`off` → false, else true); * `plan/mode` commits and clears it. `wanted` is exposed for the prompt - * boundary (the fixture's agent/step parallel). + * boundary (the fixture's step/start parallel). */ function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } { let active = false @@ -1189,7 +1189,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { nextTurn.set(id, turn + 1) setRunning(id, true) append(id, { type: 'turn/start', data: { turn } }) - // Boundary flush parallel (the host's agent/step seam): an outstanding + // Boundary flush parallel (the host's step/start observer): an outstanding // /plan selection commits as plan/mode inside the opened turn. const plan = foldPlan(logOf(id)) if (plan.wanted !== null && plan.wanted !== plan.active) { diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 75fa6b2c6c..5dd82a59ea 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: a116a5e4ad3070f20e6d90490f2507c1e2369c37 -README.zh.md: f375811e6f1480d6636fe4eb77746b76d6414b1e +README.md: 645398e5efb429a82f612f2e6540924afd46cfd4 +README.zh.md: 572fe07128294bedae05a967ea8eee23532e4feb diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index a116a5e4ad..645398e5ef 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -18,7 +18,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## Pending queue projection -`ConversationSnapshot.queue` is the Host's authoritative transient Queue snapshot; pending steering stays outside this projection. Each row carries its `InboxItemId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit/remove operations without optimistic mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`. +`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`. ## Code Mode sub-dispatch index diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index f375811e6f..572fe07128 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -18,7 +18,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 待处理队列投影 -`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 Queue 快照;待处理 steering(中途引导)不进入此投影。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑/移除操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果,认领竞态则会返回 `queue-item-not-found`。 +`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering(中途引导)不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果,claim 竞态则会返回 `queue-item-not-found`。 ## Code Mode 子调用索引 diff --git a/packages/compact/compact-basic/README.i18n.yaml b/packages/compact/compact-basic/README.i18n.yaml index c7cb49d338..e7104a1c21 100644 --- a/packages/compact/compact-basic/README.i18n.yaml +++ b/packages/compact/compact-basic/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compact/compact-basic/README.md -README.md: 775355f1ac1a7c79c16f66a5b2489d73df7b960d -README.zh.md: bfa139596b5ef61c23d29575bdea5534fa82b158 +README.md: eed9412e335a0b703f3d1d5cb7112de10a19e9a0 +README.zh.md: 763736760e4e7a6acd34a0a085e0e3b685f0a8c9 diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 775355f1ac..eed9412e33 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -17,7 +17,7 @@ This backend owns the compaction policy: - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. - **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. -- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/step` listener checks pressure before request derivation. A canonical provider overflow is offered through `agent/request-error` after the failed step; the plugin compacts there and returns a retry action only after durable surface progress. +- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/pre-step` listener checks pressure before request derivation. A canonical provider overflow is offered through `agent/request-error` after the failed step; the plugin compacts there and returns a retry action only after durable surface progress. - **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. - **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational pressure failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress. diff --git a/packages/compact/compact-basic/README.zh.md b/packages/compact/compact-basic/README.zh.md index bfa139596b..763736760e 100644 --- a/packages/compact/compact-basic/README.zh.md +++ b/packages/compact/compact-basic/README.zh.md @@ -17,7 +17,7 @@ - **收敛**:最多按 `compactionRetries` 重试头部检查点压缩;拒绝不能缩小源内容的摘要,如果重试仍无法回到阈值以下,则抛出异常。 - **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent 目标,而不运行仅用于 agent loop 的 `agent/request` seam。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息,并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。它将 `GenerateOptions.purpose` 设为 `compaction`,适配器可将其作为请求归因转发(DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`),但不会触碰模型可见的请求体。只有返回的文本会进入检查点;推理(reasoning)和工具调用都会被排除,以免泄露私有推理或产生遗留调用。 - **框定**:替换 user 消息使用 `` 标签标记已建立的检查点上下文。原始摘要保留在溯源事件上,后续自动周期会合并之前的检查点。 -- **生命周期**:`compactRegion()` 会更改 `agent.session`,并记录开始、摘要、替换与结束。异步摘要后,如果表层节点快照已改变,它会拒绝操作,而不相关的仅日志事件可以追加,不会使已选 span 失效。串行 `agent/step` listener 会在派生请求之前检查压力。规范提供方溢出会在失败步骤之后经由 `agent/request-error` 交给本插件;插件在此执行压缩,并且只在表层取得持久进展后才返回重试动作。 +- **生命周期**:`compactRegion()` 会更改 `agent.session`,并记录开始、摘要、替换与结束。异步摘要后,如果表层节点快照已改变,它会拒绝操作,而不相关的仅日志事件可以追加,不会使已选 span 失效。串行 `agent/pre-step` listener 会在派生请求之前检查压力。规范提供方溢出会在失败步骤之后经由 `agent/request-error` 交给本插件;插件在此执行压缩,并且只在表层取得持久进展后才返回重试动作。 - **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、目标特定上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。 - **失败处理**:未配对的 `compact/start` 是不起作用的崩溃标记,因为没有摘要替换落地。区域失败会记录错误结束;除非剪枝已落地,否则表层保持不变。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。即使已经取得进展,取消仍具有最终决定权。 diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 2c94d7a537..846ae2e0d1 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -11,7 +11,7 @@ import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compa import type { Session } from '@deepseek-ai/dsh-session' import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm' import type { ContentBlock, LlmCallConfig } from '@deepseek-ai/dsh-llm' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' // Type-only: makes the optional sibling service available to `ctx.get()`. import type {} from '@deepseek-ai/dsh-compact-tool-result-prune' import { @@ -135,24 +135,26 @@ export class BasicCompactService extends CompactService { ) } - ctx.on('agent/step', async ( + ctx.on('agent/pre-step', async ( agent: Agent, - _turn: number, - _step: number, - signal: AbortSignal, - ) => { - if (signal.aborted) return - try { - const result = await this.compactIfNeeded(agent, 'pressure', signal) - if (result !== null) logResult(result, 'step pressure') - } catch (error: unknown) { - if (error instanceof TargetPressureConfigError) { - if (this.warnedPressureConfigTargets.has(error.targetKey)) return - this.warnedPressureConfigTargets.add(error.targetKey) + _messages, + { signal }, + next, + ): Promise => { + if (!signal.aborted) { + try { + const result = await this.compactIfNeeded(agent, 'pressure', signal) + if (result !== null) logResult(result, 'step pressure') + } catch (error: unknown) { + if (error instanceof TargetPressureConfigError) { + if (this.warnedPressureConfigTargets.has(error.targetKey)) return next() + this.warnedPressureConfigTargets.add(error.targetKey) + } + const message = error instanceof Error ? error.message : String(error) + ctx.logger.warn(`step compaction failed: ${message}; continuing the turn`) } - const message = error instanceof Error ? error.message : String(error) - ctx.logger.warn(`step compaction failed: ${message}; continuing the turn`) } + return next() }) ctx.on('agent/status', (agent, status) => { diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 00f63ffa56..f4c12f9e75 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1369,8 +1369,11 @@ describe('default one-shot summarizer', () => { }) describe('automatic listener and loader composition', () => { - function postStep(ctx: Context, owner: Agent, signal = SIGNAL): Promise { - return agentEvents(ctx, owner).serial('agent/step', 1, 1, signal) + function preStep(ctx: Context, owner: Agent, signal = SIGNAL) { + return agentEvents(ctx, owner).waterfall( + 'agent/pre-step', [], { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) } function recover( @@ -1394,23 +1397,23 @@ describe('automatic listener and loader composition', () => { return Object.assign(new Error(message), { code: CONTEXT_WINDOW_EXCEEDED_CODE }) } - it('compacts post-step above threshold using the durable routed model and remains idle below it', async () => { + it('compacts before a step above threshold using the durable routed model and remains idle below it', async () => { const ctx = createContext() const compact = new TestCompactService(ctx, { thresholdRatio: 0.5, retainTokens: 180, }) const pressured = conversation(4) - await postStep(ctx, agent(pressured, 'unconfigured-agent-fallback')) + await preStep(ctx, agent(pressured, 'unconfigured-agent-fallback')) expect(pressured.events.some(event => event.type === 'compact/summary')).toBe(true) const small = conversation(1) - await postStep(ctx, agent(small, MODEL)) + await preStep(ctx, agent(small, MODEL)) expect(small.events.some(event => event.type === 'compact/start')).toBe(false) expect(compact.calls).toHaveLength(1) }) - it('skips post-step pressure when the step signal is already aborted', async () => { + it('skips pre-step pressure when the step signal is already aborted', async () => { const ctx = createContext() const compact = new TestCompactService(ctx, { thresholdRatio: 0.5, @@ -1419,8 +1422,8 @@ describe('automatic listener and loader composition', () => { const pressured = conversation(4) const compactIfNeeded = vi.spyOn(compact, 'compactIfNeeded') - await expect(postStep(ctx, agent(pressured, MODEL), AbortSignal.abort('step aborted'))) - .resolves.toBeUndefined() + await expect(preStep(ctx, agent(pressured, MODEL), AbortSignal.abort('step aborted'))) + .resolves.toEqual({ kind: 'enter', messages: [] }) expect(compactIfNeeded).not.toHaveBeenCalled() expect(pressured.events.some(event => event.type === 'compact/start')).toBe(false) @@ -1437,7 +1440,7 @@ describe('automatic listener and loader composition', () => { compact.error = 'temporary failure' const session = conversation(4) - await expect(postStep(ctx, agent(session, MODEL))).resolves.toBeUndefined() + await expect(preStep(ctx, agent(session, MODEL))).resolves.toEqual({ kind: 'enter', messages: [] }) expect(warnings).toContainEqual(expect.stringContaining('temporary failure')) expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) }) @@ -1457,8 +1460,8 @@ describe('automatic listener and loader composition', () => { }) const session = conversation(4) - await postStep(ctx, agent(session, MODEL)) - await postStep(ctx, agent(session, MODEL)) + await preStep(ctx, agent(session, MODEL)) + await preStep(ctx, agent(session, MODEL)) expect(warnings).toEqual([ expect.stringContaining(`no context capacity for ${MODEL}/${MODEL}`), @@ -1475,8 +1478,8 @@ describe('automatic listener and loader composition', () => { }) const session = conversation(4) - await postStep(ctx, agent(session, MODEL)) - await postStep(ctx, agent(session, MODEL)) + await preStep(ctx, agent(session, MODEL)) + await preStep(ctx, agent(session, MODEL)) expect(warnings).toEqual([ expect.stringContaining('retainTokens (500) must be less than threshold tokens 500'), @@ -1759,7 +1762,7 @@ describe('automatic listener and loader composition', () => { retainTokens: 180, }) const session = conversation(4) - await postStep(ctx, agent(session, MODEL)) + await preStep(ctx, agent(session, MODEL)) const summaries = session.events.filter(event => event.type === 'compact/summary').length expect(summaries).toBe(1) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) @@ -1774,7 +1777,7 @@ describe('automatic listener and loader composition', () => { retainTokens: 180, }) const session = conversation(4) - await postStep(ctx, agent(session, MODEL)) + await preStep(ctx, agent(session, MODEL)) expect(session.events.some(event => event.type === 'compact/start')).toBe(false) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) }) @@ -1803,7 +1806,7 @@ describe('automatic listener and loader composition', () => { await fiber.dispose() const session = conversation(4) - await postStep(ctx, agent(session, MODEL)) + await preStep(ctx, agent(session, MODEL)) expect(session.events.some(event => event.type === 'compact/start')).toBe(false) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) }) diff --git a/packages/context/README.i18n.yaml b/packages/context/README.i18n.yaml index 7836a2a03c..09fe145db9 100644 --- a/packages/context/README.i18n.yaml +++ b/packages/context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/README.md -README.md: fce6e21816d261171aaeaa217171580adb7c43f9 -README.zh.md: b8a4d68ca6892b51ed52479a7296513f5edcc292 +README.md: 3afb86c86715dda3f0b74b7af0e007c56fcd25d9 +README.zh.md: bbb390f033653914f51a6e7535631a1439cac973 diff --git a/packages/context/README.md b/packages/context/README.md index fce6e21816..3afb86c867 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -8,7 +8,7 @@ Product plugins that add model-visible request context without defining a tool. |---|---|---| | `session-reference/` | Bounded current-surface snapshots of other sessions | `ctx.sessionReferences` | | `time-context/` | Durable per-step current time and elapsed-time context | (none) | -| `tmux-context/` | Durable per-turn context with this agent's tmux pane/window location | (listens on `agent/step`, reads `ctx.bash`) | -| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/step` + `tools/post-execute`) | +| `tmux-context/` | Durable per-turn context with this agent's tmux pane/window location | (listens on `agent/pre-step`, reads `ctx.bash`) | +| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/pre-step` + `tools/post-execute`) | The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split. diff --git a/packages/context/README.zh.md b/packages/context/README.zh.md index b8a4d68ca6..bbb390f033 100644 --- a/packages/context/README.zh.md +++ b/packages/context/README.zh.md @@ -8,7 +8,7 @@ |---|---|---| | `session-reference/` | 其他会话当前表层的有界快照 | `ctx.sessionReferences` | | `time-context/` | 持久化的逐步骤当前时间与已用时上下文 | (无) | -| `tmux-context/` | 持久化的逐轮次上下文,记录本 agent 所在的 tmux pane/window 位置 | (监听 `agent/step`,读取 `ctx.bash`) | -| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` 工作区上下文 loader | (监听 `agent/step` + `tools/post-execute`) | +| `tmux-context/` | 持久化的逐轮次上下文,记录本 agent 所在的 tmux pane/window 位置 | (监听 `agent/pre-step`,读取 `ctx.bash`) | +| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` 工作区上下文 loader | (监听 `agent/pre-step` + `tools/post-execute`) | [`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了每个 agent(智能体)和会话各自隔离的方式,以及相应的生命周期拆分。 diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index fff2b196ad..3fb449cc4e 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/session-reference/README.md -README.md: 66df45b18df6d859239c8d3216d6c8b9fa61ab23 -README.zh.md: b59a98b86baf88f53429bc2409ad1776b1638736 +README.md: dec478467929fe20a9866bea29c6e1d03e4d6408 +README.zh.md: 420973cd786afe7db5f0c44ff5af7b9d2135c056 diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index 66df45b18d..dec4784679 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -14,7 +14,7 @@ English | [中文](README.zh.md) Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, direct-user `steering/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compact` source marker from the folded current surface. For a source prompt that already contains baked prefix context, projection reads only its model-hidden display content, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, context, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text. -The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. The standard TUI preserves admission ownership without attaching context to the generic inbox record: outside the next-step acceptance window, a one-shot `agent/prompt-submit` wrapper adds the snapshot only to an allowed decision; during prompt admission or an open turn, `inject()` and `steer()` stage beside each other for the same safe boundary. The target log therefore records a sourced context `user/message` followed by the readable direct `user/message` or `steering/message`. Later source mutation, compaction, or deletion cannot change target replay. +The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. When the agent is idle, the standard TUI installs a one-shot `agent/pre-step` wrapper that adds the snapshot only to an `enter` decision containing the claimed direct prompt. While the agent is running, it calls `inject()` immediately before `steer()`, placing both messages in the next-step inbox for the same later claim. The target log therefore records a sourced context `user/message` followed by the readable direct `user/message` or `steering/message`. Later source mutation, compaction, or deletion cannot change target replay. ## Configuration diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index b59a98b86b..420973cd78 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -14,7 +14,7 @@ 准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`,入队后绝不重读。它仅投影折叠后当前表层中的用户直接发出的 `user/message`、用户直接发出的 `steering/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compact` 源标记。对于已经包含固化前缀上下文的源提示词,投影只读取其对模型隐藏的显示内容,以防止快照递归传播。已遮蔽的压缩(compaction)前事件、工具、推理(reasoning)、上下文、除已标记 compact 检查点外的插件生成 user 消息,以及未完成的 assistant 分片均会被排除。因此,已压缩源只会提供最新检查点及其后保留的会话内容,不会还原已遮蔽的文本。 -上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。标准 TUI 在不把上下文附加到通用 inbox 记录的情况下保留接纳归属:next-step 接收窗口之外,一次性 `agent/prompt-submit` 包装层只为获准决策添加快照;提示词接纳期间或轮次打开时,`inject()` 与 `steer()` 会并排暂存到同一安全边界。目标日志因此会先记录一条带来源信息的上下文 `user/message`,再记录可读的直接 `user/message` 或 `steering/message`。后续源变更、压缩或删除都无法改变目标回放。 +上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。agent 空闲时,标准 TUI 会安装一次性的 `agent/pre-step` 包装层,只把快照添加到包含已领取直接提示词的 `enter` 决策。agent 运行时,它会紧接着调用 `inject()` 和 `steer()`,把两条消息放入 next-step inbox,等待后续同一次领取。目标日志因此会先记录一条带来源信息的上下文 `user/message`,再记录可读的直接 `user/message` 或 `steering/message`。后续源变更、压缩或删除都无法改变目标回放。 ## 配置 diff --git a/packages/context/time-context/README.i18n.yaml b/packages/context/time-context/README.i18n.yaml index d54c4ddde0..358d6971c2 100644 --- a/packages/context/time-context/README.i18n.yaml +++ b/packages/context/time-context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/time-context/README.md -README.md: 9fe818855439466b2a3e349cd54a2f408cf5ec10 -README.zh.md: 1133715ebb3348d6e3dbbf8bbef169d6d8c56d8f +README.md: 9956918c63b49de8ec5e739bc3d9887e269930a8 +README.zh.md: cd065f7112d42c6b949e279485c4b6b3663c2314 diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index 9fe8188554..9956918c63 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -16,21 +16,21 @@ Opt-in durable context with the current zoned time and elapsed time sampled duri When `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit `timeZone` must be an IANA identifier and is validated at plugin load. -`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` appends on every pre-step attempt whose signal is not already aborted. A positive value appends only when the session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds have elapsed since the latest injection. +`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every eligible entering pre-step whose signal is not already aborted. A positive value adds it only when the session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds have elapsed since the latest injection. ## Timing semantics -The plugin prepends an `agent/step` listener. When an injection is due, it appends one injected `user/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing. +The plugin prepends an `agent/pre-step` listener. When an injection is due and the downstream decision enters the proposed step, it adds one sourced `UserMessage` to the returned batch. AgentLoop records that context after `step/start` and before ordinary automatic compaction with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed, rejected, or failed pre-step records nothing. Positive-interval scheduling scans the raw durable session events for the latest `user/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently. Step 1 measures from the latest preceding model-visible message, including the prompt that opened the turn. Later steps measure from the preceding time-context event in the same turn. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline, or a later step with no earlier same-turn reading because interval suppression skipped it, reports `unavailable`. -A time reading records a request-preparation attempt, not a committed step or transmitted request. Because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt; the log is append-only and the plugin performs no rollback. +A time reading records an entered pre-step batch, not a completed step or transmitted request. A later request-preparation failure can therefore leave the reading in history, but a downstream pre-step listener that rejects or fails prevents it from being recorded. The separately published `./invariant` companion checks each plugin-attributed reading against the open turn, next pre-step position, elapsed baseline, and durable event time. Its rendered timestamp must parse and cannot postdate the event; process suspension between sampling and append does not invalidate the reading. -The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix at each `step/start`, so transmitted requests need not map one-to-one to readings: a failed preparation can leave an extra reading, while interval suppression can let a request reuse existing history without adding one. +The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix after each `step/start`, so transmitted requests need not map one-to-one to readings: request preparation can fail after step entry, while interval suppression can let a request reuse existing history without adding one. ## Model Experience diff --git a/packages/context/time-context/README.zh.md b/packages/context/time-context/README.zh.md index 1133715ebb..cd065f7112 100644 --- a/packages/context/time-context/README.zh.md +++ b/packages/context/time-context/README.zh.md @@ -16,21 +16,21 @@ 省略 `timeZone` 时,插件会在加载时解析一次 Node 进程的系统时区。Node 遵循 `TZ`;如果没有该覆盖,时区由宿主或容器提供。显式 `timeZone` 必须是 IANA 标识符,并在插件加载时验证。 -`refreshIntervalMs` 必须是非负安全整数。省略或设为 `0` 时,对每次信号尚未中止的合格步骤前尝试执行追加。正数值只会在会话没有早先 time-context 注入、挂钟时间倒退,或自最新注入起已经过至少相应毫秒数时执行追加。 +`refreshIntervalMs` 必须是非负安全整数。省略或设为 `0` 时,会为每次信号尚未中止且返回 enter 的合格 pre-step 添加上下文。正数值只会在会话没有早先 time-context 注入、挂钟时间倒退,或自最新注入起已经过至少相应毫秒数时添加上下文。 ## 时序语义 -该插件会前置一个 `agent/step` 监听器。需要注入时,它会追加一条注入的 `user/message`,通过 `agent.inject()` 完成,时机位于 `step/start` 和普通自动压缩(compaction)之前,其源为 `{ kind: 'plugin', plugin: 'time-context' }`。被抑制的尝试不追加任何内容。 +该插件会前置一个 `agent/pre-step` 监听器。需要注入且下游决策进入拟议步骤时,它会在返回批次中添加一条带来源的 `UserMessage`。AgentLoop 会在 `step/start` 之后、普通自动压缩(compaction)之前记录该上下文,其来源为 `{ kind: 'plugin', plugin: 'time-context' }`。被抑制、reject 或失败的 pre-step 不会记录任何内容。 正间隔调度会扫描原始持久会话事件,查找最新的上述源 `user/message`,包括已被压缩遮蔽的时间读数。因此,调度可以跨轮次以及进程恢复持续生效,不需要进程本地缓存状态。它会降低追加频率与历史增长,但绝不移除现有时间读数,且每个会话独立调度。 第 1 步从前一条模型可见消息起测量,包括开启轮次的提示词。后续步骤从同一轮次中前一个 time-context 事件起测量。两种基线都使用持久会话事件时间戳;挂钟时间倒退时,经过时长限制为零。如果第一步缺少基线,或者后续步骤因间隔抑制而没有较早的同轮次时间读数,则报告 `unavailable`。 -时间读数记录的是一次请求准备尝试,不是已提交步骤或已传输请求。因为 listener 首先运行,后续的步骤前监听器取消或使该尝试失败时,该追加可能仍会保留。日志仅追加,该插件不执行回滚。 +时间读数记录的是一个已进入步骤的 pre-step 批次,不是已完成步骤或已传输请求。后续请求准备失败时,该读数可能已留在历史中;但下游 pre-step 监听器 reject 或失败时,该读数不会被记录。 单独发布的 `./invariant` 配套模块会根据当前未结束的轮次、下一个步骤前位置、经过时长基线与持久事件时间检查每个归因于插件的时间读数。其渲染时间戳必须可解析,且不能晚于该事件;采样与追加之间的进程挂起不会使时间读数失效。 -时间读数会保留在派生会话历史中,直到后续压缩遮蔽它。请求标头不含 time-context 状态。请求重建会在每个 `step/start` 处使用完整持久表层前缀,因此已传输请求无需与时间读数一一对应:失败的准备可能留下额外时间读数,而间隔抑制可让请求复用现有历史,无需添加时间读数。 +时间读数会保留在派生会话历史中,直到后续压缩遮蔽它。请求标头不含 time-context 状态。请求重建会在每个 `step/start` 之后使用完整持久表层前缀,因此已传输请求无需与时间读数一一对应:请求准备可能在进入步骤后失败,而间隔抑制可让请求复用现有历史,无需添加时间读数。 ## 模型体验 diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 6762498bc4..d579dd6421 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -1,26 +1,26 @@ /** - * Opt-in request-preparation clock context. Eligible pre-step attempts append - * durable, source-attributed time readings to conversation history. + * Opt-in request clock context. Eligible steps add durable, + * source-attributed time readings to the request history. * * @module @deepseek-ai/dsh-time-context */ import type { Context } from 'cordis' import z from 'schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' /** Cordis plugin name used by loader diagnostics. */ export const name = 'time-context' -/** The agent registry that owns the pre-step lifecycle seam. */ +/** The agent registry that owns pre-step processing. */ export const inject = ['agents'] /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ export interface Config { /** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */ timeZone?: string - /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible pre-step attempt. */ + /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject at every eligible step. */ refreshIntervalMs?: number } @@ -157,23 +157,33 @@ export function apply(ctx: Context, config: Config): void { } const resolvedTimeZone = formatter.resolvedOptions().timeZone - ctx.on('agent/step', ( + ctx.on('agent/pre-step', async ( agent: Agent, - turn: number, - step: number, - signal: AbortSignal, - ) => { - if (signal.aborted) return + _messages, + { turn, step, signal }, + next, + ): Promise => { + const decision = await next() + if (decision.kind === 'reject' || signal.aborted) return decision const now = Date.now() if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) { const lastInjection = latestInjectionTime(agent) if (lastInjection !== undefined && now >= lastInjection - && now - lastInjection < refreshIntervalMs) return + && now - lastInjection < refreshIntervalMs) return decision } const previous = step === 1 ? precedingMessageTime(agent) : precedingStepContextTime(agent, turn) - agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }], source: { kind: 'plugin', plugin: name } }), { surfaceOp: 'append' }) + return { + kind: 'enter', + messages: [ + ...decision.messages, + createUserMessage({ + content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }], + source: { kind: 'plugin', plugin: name }, + }), + ], + } }, { prepend: true }) } diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 37b5a08610..d561b69308 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -18,22 +18,23 @@ export const name = 'time-context-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Derive the open step in which a time-context reading may append. */ +/** Derive the next request boundary at which a time-context reading may append. */ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } { for (const event of history.slice().reverse()) { switch (event.type) { - case 'step/start': - return event.data case 'step/end': + return { turn: event.data.turn, step: event.data.step + 1 } case 'turn/start': + return { turn: event.data.turn, step: 1 } + case 'step/start': case 'turn/end': - fail('time-context reading must be appended inside an open step') + fail('time-context reading must be appended at a prompt boundary') break default: break } } - fail('time-context reading must be appended inside an open step') + fail('time-context reading must be appended at a prompt boundary') } /** Validate one plugin-attributed time reading against its session position and timestamp. */ diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index db64595938..3a161fb0ff 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -58,7 +58,6 @@ function preparing(turn: number, step: number): Session { session.append('step/start', { turn, step: priorStep }) session.append('step/end', { turn, step: priorStep }) } - session.append('step/start', { turn, step }) return session } @@ -93,7 +92,6 @@ describe('time-context invariants', () => { content: [{ type: 'text', text: 'prepare' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - session.append('step/start', { turn: 1, step: 1 }) appendReading(session, reading()) await ctx.plugin(InvariantService, { enabled: true }) @@ -109,7 +107,6 @@ describe('time-context invariants', () => { content: [{ type: 'text', text: 'prepare' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - session.append('step/start', { turn: 1, step: 1 }) appendReading(session, reading('1', '2', 'step context')) await ctx.plugin(InvariantService, { enabled: true }) @@ -129,17 +126,17 @@ describe('time-context invariants', () => { const session = preparing(1, 2) session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } }) expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) }) - .toThrow(/inside an open step/) + .toThrow(/at a prompt boundary/) }) - it('rejects a reading outside an open step', async () => { + it('rejects a reading outside a prompt boundary', async () => { const ctx = await setup() const ended = preparing(1, 1) - ended.append('step/end', { turn: 1, step: 1 }) - expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/inside an open step/) + ended.append('step/start', { turn: 1, step: 1 }) + expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/at a prompt boundary/) expect(() => { ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading())) - }).toThrow(/inside an open step/) + }).toThrow(/at a prompt boundary/) }) it.each([ diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index fb246cf068..38ea62f2ed 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -40,7 +40,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { id: SessionId(id), options: {}, session, - inbox: new Inbox(session), + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'running', ctx: new Context(), send: () => {}, @@ -79,7 +79,17 @@ async function fire( step: number, signal: AbortSignal = SIGNAL, ): Promise { - await agentEvents(ctx, agent).serial('agent/step', turn, step, signal) + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + [], + { turn, step, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) + if (decision.kind === 'enter') { + for (const message of decision.messages) { + agent.session.append('user/message', message, { surfaceOp: 'append' }) + } + } } function textResponse(text: string): StreamChunk[] { @@ -282,22 +292,17 @@ describe('durable step context', () => { expect(contextTexts(independent)).toHaveLength(1) }) - it('runs before ordinary pre-step listeners and skips an already-aborted step', async () => { + it('skips an already-aborted prompt submission', async () => { const { ctx } = await mount() const session = new Session(SessionId('ordering')) const agent = sessionAgent(session) openMessageTurn(session, 1) - let ordinarySawContext = false - ctx.on('agent/step', (subject) => { - ordinarySawContext = subject.session.events.some(event => event.type === 'user/message') - }) await fire(ctx, agent, 1, 1) const abort = new AbortController() abort.abort() await fire(ctx, agent, 1, 2, abort.signal) - expect(ordinarySawContext).toBe(true) expect(contextTexts(session)).toHaveLength(1) }) }) @@ -355,28 +360,24 @@ describe('configuration and lifecycle', () => { describe('real agent-loop request history', () => { it.each([ - ['throws', 'error'], - ['cancels', 'aborted'], - ] as const)('retains the durable preparation reading when a later step listener %s', async (mode, reasonKind) => { + ['throws'], + ['cancels'], + ] as const)('does not commit a preparation reading when a downstream pre-step listener %s', async (mode) => { const adapter = new ScriptedAdapter([textResponse('unused')]) const ctx = await loopHarness(adapter) - let laterSawReading = false - ctx.on('agent/step', (subject) => { - laterSawReading = contextTexts(subject.session).length === 1 + ctx.on('agent/pre-step', (subject, _messages, _context, next) => { if (mode === 'throws') throw new Error('later pre-step failure') subject.cancel({ kind: 'user' }) + return next() }) const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } })) await agent.whenIdle() - expect(laterSawReading).toBe(true) - expect(contextTexts(agent.session)).toHaveLength(1) + expect(contextTexts(agent.session)).toHaveLength(0) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.some(event => event.type === 'step/start')).toBe(true) - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe(reasonKind) + expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false) await ctx.fiber.dispose() }) @@ -404,7 +405,7 @@ describe('real agent-loop request history', () => { expect(contexts).toHaveLength(adapter.requests.length) expect(starts).toHaveLength(adapter.requests.length) for (let index = 0; index < contexts.length; index += 1) { - expect(contexts[index]!.seq).toBeGreaterThan(starts[index]!.seq) + expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq) } expect(contexts.every(event => event.data.source.kind === 'plugin' && event.data.source.plugin === 'time-context' @@ -413,7 +414,7 @@ describe('real agent-loop request history', () => { const firstRequestText = requestText(adapter.requests[0]!) const secondRequestText = requestText(adapter.requests[1]!) expect(firstRequestText).toContain('Time sampled while preparing turn 1, step 1:') - expect(firstRequestText).toContain('Elapsed since the preceding model-visible message: 0s.') + expect(firstRequestText).toContain('Elapsed since the preceding model-visible message: unavailable.') expect(firstRequestText).not.toContain('Time sampled while preparing turn 1, step 2:') expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 1:') expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 2:') diff --git a/packages/context/tmux-context/README.i18n.yaml b/packages/context/tmux-context/README.i18n.yaml index 9a6f113cc8..219d5567c6 100644 --- a/packages/context/tmux-context/README.i18n.yaml +++ b/packages/context/tmux-context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/tmux-context/README.md -README.md: a166a46d20f472cb5d8f045e2456ce3e6de7a2f2 -README.zh.md: 0575d549e352239e7d954870eaf40beea1169cc6 +README.md: 9e1afac16ecff0a026d9c20cba2e40ee5fad6792 +README.zh.md: d1ebdc140b0b71ed344ec918993d60e2510780e8 diff --git a/packages/context/tmux-context/README.md b/packages/context/tmux-context/README.md index a166a46d20..9e1afac16e 100644 --- a/packages/context/tmux-context/README.md +++ b/packages/context/tmux-context/README.md @@ -17,7 +17,7 @@ Opt-in durable context naming the tmux session, window, and pane this agent proc ## How it reads tmux -The plugin prepends an `agent/step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.bash` executor seam: +The plugin prepends an `agent/pre-step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.bash` executor seam: ```sh [ -n "$TMUX_PANE" ] || exit 1 @@ -33,7 +33,7 @@ State is pulled on every eligible turn — a moved, renamed, or re-laid-out pane ## Timing semantics -When an injection is due, the plugin appends one injected `user/message` through `agent.inject()` before `step/start`, with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression and interval scheduling scan the raw durable session events for the latest injection of this source, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. The reading records a request-preparation attempt, not a committed step; because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt (the log is append-only and the plugin performs no rollback). +The plugin prepends an `agent/pre-step` listener. When an injection is due and the downstream decision enters the proposed step, it prepends one sourced `UserMessage` to the returned batch. AgentLoop records that context after `step/start` with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression and interval scheduling scan the raw durable session events for the latest injection of this source, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. A downstream pre-step listener that rejects or fails prevents the reading from being recorded. ## Model Experience diff --git a/packages/context/tmux-context/README.zh.md b/packages/context/tmux-context/README.zh.md index 0575d549e3..d1ebdc140b 100644 --- a/packages/context/tmux-context/README.zh.md +++ b/packages/context/tmux-context/README.zh.md @@ -17,7 +17,7 @@ ## 如何读取 tmux -插件前置注册一个 `agent/step` 监听器,仅在每轮的第一个 step 运行。当需要注入时,它通过 `ctx.bash` 执行器 seam 运行一条只读命令: +插件前置注册一个 `agent/pre-step` 监听器,仅在每轮的第一个 step 运行。当需要注入时,它通过 `ctx.bash` 执行器 seam 运行一条只读命令: ```sh [ -n "$TMUX_PANE" ] || exit 1 @@ -33,7 +33,7 @@ exec tmux display-message -t "$TMUX_PANE" -p '' ## 时序语义 -当需要注入时,插件在 `step/start` 之前通过 `agent.inject()` 追加一条注入的 `user/message`,来源为 `{ kind: 'plugin', plugin: 'tmux-context' }`。变化抑制与间隔调度会扫描原始持久会话事件中该来源的最近一次注入,因此调度可跨压缩与恢复的进程存续,无需进程内缓存状态;各会话独立调度。该读数记录的是一次请求准备尝试,而非已提交的 step;由于监听器最先运行,当后续 pre-step 监听器取消或失败时,它的追加可能仍会保留(日志只追加,插件不做回滚)。 +该插件会前置一个 `agent/pre-step` 监听器。需要注入且下游决策进入拟议步骤时,它会在返回批次前添加一条带来源的 `UserMessage`。AgentLoop 会在 `step/start` 之后记录该上下文,其来源为 `{ kind: 'plugin', plugin: 'tmux-context' }`。变化抑制与间隔调度会扫描原始持久会话事件中该来源的最近一次注入,因此调度可跨压缩与恢复的进程存续,无需进程内缓存状态;各会话独立调度。下游 pre-step 监听器 reject 或失败时,该读数不会被记录。 ## 模型体验 diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index 4e1694ca1a..130efb919b 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -3,7 +3,7 @@ * append durable, source-attributed context naming the tmux session, window, * and pane this agent process runs in, plus the window's pane-tree layout. * - * The plugin pulls state once per turn, on the first step (`step === 1`), by + * The plugin pulls state once per turn, for the first request (`step === 1`), by * running one `tmux display-message` through the `ctx.bash` executor seam. It * confirms this process genuinely runs inside the pane `$TMUX_PANE` names by * matching the pane's `#{pane_tty}` against this process's controlling terminal, @@ -20,14 +20,14 @@ import type { Context, LoggerService } from 'cordis' import z from 'schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import type { BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash' import { createUserMessage } from '@deepseek-ai/dsh-llm' /** Cordis plugin name used by loader diagnostics. */ export const name = 'tmux-context' -/** The agent registry that owns the `agent/step` lifecycle seam. */ +/** The agent registry that owns pre-step processing. */ export const inject = ['agents'] /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ @@ -206,7 +206,7 @@ function validateRefreshInterval(refreshIntervalMs: number | undefined): void { } /** - * Register a prepended `agent/step` listener for the lifetime of `ctx`. + * Register a prepended pre-step listener for the lifetime of `ctx`. * @param ctx - plugin context; the listener is disposed with it. * @param config - durable refresh scheduling configuration. * @throws when the refresh interval is invalid. @@ -215,27 +215,34 @@ export function apply(ctx: Context, config: Config): void { const refreshIntervalMs = config.refreshIntervalMs validateRefreshInterval(refreshIntervalMs) - ctx.on('agent/step', async ( + ctx.on('agent/pre-step', async ( agent: Agent, - turn: number, - step: number, - signal: AbortSignal, - ): Promise => { - if (signal.aborted || step !== 1) return + _messages, + { turn, step, signal }, + next, + ): Promise => { + const decision = await next() + if (decision.kind === 'reject' || signal.aborted || step !== 1) return decision const bash = ctx.get('bash') - if (bash === undefined) return + if (bash === undefined) return decision const previous = latestInjectedState(agent) if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== undefined) { const now = Date.now() - if (now >= previous.time && now - previous.time < refreshIntervalMs) return + if (now >= previous.time && now - previous.time < refreshIntervalMs) return decision } const location = await queryTmuxLocation(bash, ctx.logger, process.pid, signal) - if (location === undefined) return + if (location === undefined) return decision const state = renderState(location) - if (previous !== undefined && previous.state === state) return - agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: renderReading(location, turn) }], - source: { kind: 'plugin', plugin: name }, - }), { surfaceOp: 'append' }) + if (previous !== undefined && previous.state === state) return decision + return { + kind: 'enter', + messages: [ + createUserMessage({ + content: [{ type: 'text', text: renderReading(location, turn) }], + source: { kind: 'plugin', plugin: name }, + }), + ...decision.messages, + ], + } }, { prepend: true }) } diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 941838c001..402f66a9e6 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -96,7 +96,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { id: SessionId(id), options: {}, session, - inbox: new Inbox(session), + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'running', ctx: new Context(), send: () => {}, @@ -135,7 +135,17 @@ async function fire( step: number, signal: AbortSignal = SIGNAL, ): Promise { - await agentEvents(ctx, agent).serial('agent/step', turn, step, signal) + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + [], + { turn, step, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) + if (decision.kind === 'enter') { + for (const message of decision.messages) { + agent.session.append('user/message', message, { surfaceOp: 'append' }) + } + } } afterEach(() => { @@ -365,19 +375,11 @@ describe('tmux-context no-op paths', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('spawn refused')) }) - it('skips an already-aborted step and runs before ordinary agent/step listeners', async () => { + it('skips an already-aborted prompt submission', async () => { const { ctx } = await mount({}, true) const session = new Session(SessionId('ordering')) const agent = sessionAgent(session) openMessageTurn(session, 1) - let ordinarySawContext = false - ctx.on('agent/step', (subject) => { - ordinarySawContext = subject.session.events.some( - event => event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'tmux-context', - ) - }) const abort = new AbortController() abort.abort() @@ -385,7 +387,6 @@ describe('tmux-context no-op paths', () => { expect(contextTexts(session)).toHaveLength(0) await fire(ctx, agent, 1, 1) - expect(ordinarySawContext).toBe(true) expect(contextTexts(session)).toHaveLength(1) }) }) diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 102c991391..52a3ede0de 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/workspace-context/README.md -README.md: 2669422ec1fa7a74ba329cd96ee6b7e5e6da7e9d -README.zh.md: e9fab4c6998f1193068389b41bdd7fa7d8c98dca +README.md: 954a032e600579dda7f11ce3d11224aff7f7666e +README.zh.md: ba945cfbf83398466c7888c5e95300dd8570870d diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index 2669422ec1..954a032e60 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -6,7 +6,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The ## Lifecycle -The baseline is injected at the first `agent/step` of each live session. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The durable sourced `user/message` enters the same request as the claimed prompt. +The first eligible `agent/pre-step` of each live session composes the baseline and prepends it to the agent's `next-step` inbox without changing the current `PreStepDecision`. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. Because the loop claims before running pre-step, the new context may enter a later request. If a previously queued workspace context is still pending, the plugin removes and replaces that exact inbox item instead of accumulating duplicates. The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file is attached through the result's `additionalContexts`; a changed file appends a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable. @@ -48,11 +48,11 @@ The plugin owns the complete `` framing, and every injected `us ## State And Refresh -Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; the complete startup or resume baseline also carries `baseline: true`. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. +Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; the complete startup or resume baseline also carries `baseline: true`. A matching durable `user/message` confirms a queued baseline and its candidate versions. If pre-step rejects or rewrites away a claimed workspace message, a later boundary recomposes and queues the current context again. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. If the owning `step/end` arrives before a matching dynamic context reaches the log, the plugin clears that pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. -The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface; the next successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it injects a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes before its first request. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop prepares its baseline. +The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface; the next successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it queues a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes at its first pre-step, but the resulting inbox context need not enter that same request. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop prepares its baseline. ## Configuration diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index e9fab4c699..ba945cfbf8 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -6,7 +6,7 @@ ## 生命周期 -基线会在每个实时会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。 +每个实时会话第一次符合条件的 `agent/pre-step` 会组合基线,将它 prepend 到 agent 的 `next-step` inbox,但不改变当前 `PreStepDecision`。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。因为循环在运行 pre-step 前已经领取消息,新上下文可能要到后续请求才会进入。若之前排队的 workspace 上下文仍在等待,插件会删除并替换该确切 inbox 条目,而不会不断累积副本。 该插件还会监听 `tools/post-execute` 中成功的第一方 `read`、`write` 和 `edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。 @@ -48,11 +48,11 @@ These instructions apply to work under `packages/app`. Use them as guidance when ## 状态与刷新 -模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整的启动或恢复基线还会携带 `baseline: true`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 +模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整的启动或恢复基线还会携带 `baseline: true`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。若 pre-step reject 或改写掉已领取的 workspace 消息,后续边界会重新组合并排队当前上下文。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。如果所属 `step/end` 在匹配的动态上下文进入日志之前到达,插件会清除该 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。 -初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态;下一次成功的文件系统 touch 会在压缩后重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复 loop 准备基线时可见。 +初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态;下一次成功的文件系统 touch 会在压缩后重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会排队当前基线。恢复的 loop 始终在第一次 pre-step 重新组合当前基线,并对账仍可见的动态 scope,但产生的 inbox 上下文不保证进入同一次请求。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复 loop 准备基线时可见。 ## 配置 diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 10fb2b0c33..322ba800bf 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -10,8 +10,9 @@ */ import type { Context } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' +import { createUserMessage, type MessageId } from '@deepseek-ai/dsh-llm' +import type { UserMessage } from '@deepseek-ai/dsh-session' import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { Config, resolveConfig, type ResolvedConfig } from './config.ts' import { loadBaselineInstructionSet } from './files.ts' @@ -27,6 +28,7 @@ import { rollbackPendingInstructionChanges, workspaceContextMessage, type InstructionVersionCache, + type InstructionVersionState, type InstructionVersionUpdate, type PendingInstructionChange, } from './state.ts' @@ -60,6 +62,10 @@ export function apply(ctx: Context, config: Config): void { const instructionVersions: InstructionVersionCache = new WeakMap() const pendingVersionUpdates = new Map() const baselineLoaded = new WeakSet() + const pendingBaselineCommits = new WeakMap + versions: Map + }>() // Sessions whose lifecycle start this mount witnessed. A startup or resume // emits agent/session-start before the first step; a hot remount attaches to // an already-live session and never sees it. Resumes always re-compose the @@ -78,18 +84,43 @@ export function apply(ctx: Context, config: Config): void { ctx.on('session/event', (session, event) => { observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions) + const pending = pendingBaselineCommits.get(session) + if (pending === undefined || event.type !== 'user/message' + || !pending.messageIds.delete(event.data.id) || pending.messageIds.size > 0) return + baselineSessions.add(session) + if (pending.versions.size === 0) instructionVersions.delete(session) + else instructionVersions.set(session, pending.versions) + baselineLoaded.add(session) + pendingBaselineCommits.delete(session) }) - ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise => { - if (baselineLoaded.has(agent.session)) return + ctx.on('agent/pre-step', async ( + agent: Agent, + _messages, + { signal }, + next, + ): Promise => { + const decision = await next() + if (signal.aborted || baselineLoaded.has(agent.session)) return decision + const previous = pendingBaselineCommits.get(agent.session) + if (decision.kind === 'enter' && previous !== undefined + && [...previous.messageIds].every(id => decision.messages.some(message => message.id === id))) { + return decision + } + if (previous !== undefined) { + for (const id of previous.messageIds) agent.inbox.remove('next-step', id) + pendingBaselineCommits.delete(agent.session) + } if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) { baselineLoaded.add(agent.session) - return + pendingBaselineCommits.delete(agent.session) + return decision } const fileSystem = ctx.get('fs') if (fileSystem === undefined) { baselineLoaded.add(agent.session) - return + pendingBaselineCommits.delete(agent.session) + return decision } /* v8 ignore next -- normal agents carry an absolute session cwd. */ const cwd = agent.session.header.cwd ?? process.cwd() @@ -104,34 +135,52 @@ export function apply(ctx: Context, config: Config): void { signal, }, fileSystem) const baseline = baselineInstructionState(instructions?.included ?? []) - baselineSessions.add(agent.session) - instructionVersions.set(agent.session, baseline.versions) + const candidateVersions: InstructionVersionCache = new WeakMap() + candidateVersions.set(agent.session, new Map(baseline.versions)) + const contexts: UserMessage[] = [] const update = await reconcileInstructionContext( agent, resolved, pendingNestedChanges, - instructionVersions, + candidateVersions, fileSystem, { includeBaselineScopes: false, signal }, ) if (update !== undefined) { - agent.session.append('user/message', update.context, { surfaceOp: 'append' }) - applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions) + contexts.push(update.context) + applyInstructionVersionUpdates(agent.session, update.versionUpdates, candidateVersions) } const keepVisibleBaseline = !lifecycleWitnessed.has(agent.session) && hasVisibleBaseline(agent) if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) { const baselineMessage = workspaceContextMessage(instructions.rendered.text) - agent.session.append('user/message', createUserMessage({ + contexts.push(createUserMessage({ content: baselineMessage.content, source: { kind: 'workspace-instructions', baseline: true, changes: [...baseline.changes.values()], }, - }), { surfaceOp: 'append' }) + })) } - baselineLoaded.add(agent.session) + const versions = candidateVersions.get(agent.session) + ?? new Map() + if (contexts.length === 0) { + baselineSessions.add(agent.session) + if (versions.size === 0) instructionVersions.delete(agent.session) + else instructionVersions.set(agent.session, versions) + baselineLoaded.add(agent.session) + pendingBaselineCommits.delete(agent.session) + return decision + } + pendingBaselineCommits.set(agent.session, { + messageIds: new Set(contexts.map(context => context.id)), + versions, + }) + for (const context of contexts.toReversed()) { + agent.inbox.prepend('next-step', context) + } + return decision }) ctx.on('tools/post-execute', async ( diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 1ed1140624..8a714748bd 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -176,7 +176,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { id: SessionId('a1'), options: {}, session, - inbox: new Inbox(session), + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', send: () => {}, followup: () => {}, @@ -231,7 +231,25 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: U const composedPrefixes = new WeakMap() async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise { - await agentEvents(ctx, agent).serial('agent/step', 1, 1, AbortSignal.timeout(1000)) + const signal = AbortSignal.timeout(1000) + await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + [], + { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) + const claimed = agent.inbox.claim('next-step') + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + claimed, + { turn: 1, step: 2, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), + ) + const entered = decision.kind === 'enter' ? decision.messages : [] + for (const message of entered) { + const event = agent.session.append('user/message', message, { surfaceOp: 'append' }) + ctx.emit('session/event', agent.session, event) + } const prefix = agent.session.deriveMessages() composedPrefixes.set(agent, prefix) return prefix @@ -1000,7 +1018,7 @@ describe('workspace context request injection', () => { } }) - it('injects one durable baseline contribution on the first step only', async () => { + it('queues and later commits one durable baseline contribution', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1022,6 +1040,66 @@ describe('workspace context request injection', () => { } }) + it('leaves the current pre-step batch unchanged while queuing the baseline', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + const prompt = createUserMessage({ + content: [{ type: 'text', text: 'current prompt' }], + source: { kind: 'user' }, + }) + + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + [prompt], + { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + () => Promise.resolve({ kind: 'enter' as const, messages: [prompt] }), + ) + + expect(decision).toEqual({ kind: 'enter', messages: [prompt] }) + expect(agent.inbox.nextStep).toHaveLength(1) + expect(blocksText(agent.inbox.nextStep[0]?.content)).toContain('Instructions from: AGENTS.md') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('retries a baseline contribution removed by an outer pre-step listener', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + const removeBaseline = ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => { + const decision = await next() + return decision.kind === 'reject' + ? decision + : { + ...decision, + messages: decision.messages.filter(message => + message.source.kind !== 'workspace-instructions'), + } + }) + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + expect(await composeBaselinePrefix(ctx, agent)).toEqual([]) + removeBaseline() + expect(blocksText((await composeBaselinePrefix(ctx, agent))[0]?.content)) + .toContain('Instructions from: AGENTS.md') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('retains a visible baseline after a plugin remount', async () => { const root = await tempRepo() const home = await tempRepo() @@ -1154,7 +1232,7 @@ describe('workspace context request injection', () => { } }) - it('places workspace instructions before later step contributors such as a skills catalog', async () => { + it('keeps an independent pre-step contribution after the queued workspace context', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1162,8 +1240,16 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - ctx.on('agent/step', (agent) => { - agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Available skills' }], source: { kind: 'plugin', plugin: 'test-skills' } }), { surfaceOp: 'append' }) + ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => { + const decision = await next() + if (decision.kind === 'reject') return decision + return { + ...decision, + messages: [ + ...decision.messages, + createUserMessage({ content: [{ type: 'text', text: 'Available skills' }], source: { kind: 'plugin', plugin: 'test-skills' } }), + ], + } }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -1420,7 +1506,7 @@ describe('workspace context request injection', () => { } }) - it('aborts an in-flight baseline stream with the step signal', async () => { + it('aborts an in-flight baseline stream with the prompt signal', async () => { const root = join(await tempRepo(), 'virtual-repo') const home = join(await tempRepo(), 'virtual-home') const ctx = new Context() @@ -1432,7 +1518,12 @@ describe('workspace context request injection', () => { await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel prefix') - const pending = agentEvents(ctx, stubAgent(root)).serial('agent/step', 1, 1, controller.signal) + const pending = agentEvents(ctx, stubAgent(root)).waterfall( + 'agent/pre-step', + [], + { turn: 1, step: 1, signal: controller.signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) await fs.started.promise controller.abort(reason) @@ -1662,7 +1753,7 @@ describe('workspace context request injection', () => { } }) - it('cleans up its agent/step listener when the plugin fiber is disposed', async () => { + it('cleans up its pre-step listener when the plugin fiber is disposed', async () => { const root = await tempRepo() const home = await tempRepo() try { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9028515eab..07f406227a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1201,11 +1201,32 @@ export const EVENT_API: readonly EventApiEntry[] = [ summary: 'A step or turn errored.', }, { - name: 'agent/prompt-submit', + name: 'agent/inbox/claimed', + mode: 'emit', + signature: '\'agent/inbox/claimed\'(this: Scoped, agent: Agent, event: { message: UserMessage; turn: number }): void', + jsDoc: '/**\n * One message left the inbox for a turn.\n * @param agent - the agent whose inbox changed.\n * @param event - the claimed message and owning turn.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'One message left the inbox for a turn.', + }, + { + name: 'agent/inbox/discarded', + mode: 'emit', + signature: '\'agent/inbox/discarded\'(this: Scoped, agent: Agent, event: { message: UserMessage }): void', + jsDoc: '/**\n * One message was discarded from the live inbox.\n * @param agent - the agent whose inbox changed.\n * @param event - the discarded message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'One message was discarded from the live inbox.', + }, + { + name: 'agent/inbox/inserted', + mode: 'emit', + signature: '\'agent/inbox/inserted\'(this: Scoped, agent: Agent, event: { message: UserMessage }): void', + jsDoc: '/**\n * One message entered the live inbox.\n * @param agent - the agent whose inbox changed.\n * @param event - the inserted message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'One message entered the live inbox.', + }, + { + name: 'agent/pre-step', mode: 'waterfall', - signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, messages: UserMessage[], signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Allow, rewrite, or block one claimed inbox batch before it becomes\n * model-visible or opens a turn. Call `next()` for the unchanged default. The\n * signal controls only this admission attempt; listeners may cooperate with\n * it but must not retain it for a later attempt or turn.\n * @param agent - the agent whose driver claimed the batch.\n * @param messages - the claimed messages.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', - summary: 'Allow, rewrite, or block one claimed inbox batch before it becomes model-visible or opens a turn.', + signature: '\'agent/pre-step\'(this: Scoped, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise): Promise', + jsDoc: '/**\n * Reject a proposed step or replace the messages that enter it. Calling\n * `next()` preserves the current messages.\n * @param agent - the agent proposing the step.\n * @param messages - messages removed from the inbox for this step.\n * @param context - proposed turn and step coordinates plus cancellation.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + summary: 'Reject a proposed step or replace the messages that enter it.', }, { name: 'agent/request', @@ -1235,13 +1256,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). A waking delivery enters\n * `running` synchronously after reserving cancellation; `idle` means no\n * driver remains scheduled or active.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'Agent status changed (`idle` ⇄ `running`).', }, - { - name: 'agent/step', - mode: 'serial', - signature: '\'agent/step\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void', - jsDoc: '/**\n * Awaited serial checkpoint before EVERY request of a turn is built (the\n * first as well as each post-tools continuation). The single "between\n * steps" extension point: inject context, steer, or edit the session log\n * here — the request\'s history derives from the log right after this settles.\n * @param agent - the agent about to send a request.\n * @param turn - the open turn number.\n * @param step - the step number about to open.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', - summary: 'Awaited serial checkpoint before EVERY request of a turn is built (the first as well as each post-tools continuation).', - }, { name: 'agent/turn-stopping', mode: 'serial', @@ -1919,7 +1933,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Inbox', - declaration: 'export class Inbox {\n constructor(private readonly session: Session);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[], outcome?: \'admitted\' | \'canceled\'): UserMessage[];\n}', + declaration: 'export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n claim(target: InboxTarget): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n update(target: InboxTarget, messageId: MessageId, newMessage: UserMessage): boolean;\n remove(target: InboxTarget, messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n}', + }, + { + name: 'InboxNotifications', + declaration: 'export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n}', }, { name: 'InboxTarget', @@ -2041,6 +2059,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PreparedReferencedMessage', declaration: 'export interface PreparedReferencedMessage {\n content: ContentBlock[];\n additionalContext?: UserMessage;\n}', }, + { + name: 'PreStepContext', + declaration: 'export interface PreStepContext {\n readonly turn: number;\n readonly step: number;\n readonly signal: AbortSignal;\n}', + }, + { + name: 'PreStepDecision', + declaration: 'export type PreStepDecision = {\n kind: \'reject\';\n} | {\n kind: \'enter\';\n messages: UserMessage[];\n};', + }, { name: 'PresetOption', declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}', @@ -2807,7 +2833,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TurnEndReasonMap', - declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason: AgentCancelCause;\n };\n error: {\n kind: \'error\';\n error: unknown;\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}', + declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason: AgentCancelCause;\n };\n blocked: {\n kind: \'blocked\';\n };\n error: {\n kind: \'error\';\n error: unknown;\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}', }, { name: 'TypertContribution', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 3a70dde1a7..fd94f8a980 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 8dee1ed49fea6c079e9c0aa1cf6e4dd1d897793a -README.zh.md: 5d69c5c28f67dac66bee0fb08bbb6be28153dd34 +README.md: 2029b9424975618beccf35c42e42d0bacb6743c0 +README.zh.md: e83739635539ce5ba6f070113c164d914ca4167f diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 39622bc584..2029b94249 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -53,11 +53,11 @@ Configured agents start automatically. A model call requires both `provider` and ### Internal concrete driver -The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. +The concrete `ReactLoopAgent`, its inbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A block's mandatory `discardClaimed` controls only its submitted batch; later next-step input and queued prompts remain pending for a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. +The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. `followup()` appends to the `next-turn` FIFO and wakes the driver, `steer()` appends to the `next-step` inbox and wakes it, and `inject()` appends to that same `next-step` inbox without waking it. At a turn boundary the driver atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. Claiming removes the batch through pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message. `agent/pre-step` then returns either rejection or the complete messages entering the proposed step. Rejection leaves the claimed batch removed; input inserted after the claim remains pending, and idle injection waits until follow-up or steering wakes the driver. -Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, while remove publishes discard. Edit publishes `agent/inbox/update`; steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`. +Every inbox mutation publishes one normalized `agent/inbox/spliced` event before changing the live projection. Insertions, edits, removals, claiming, and cancellation replay through the same standard splice coordinates. Ordinary removals carry `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; claiming uses pure deletions with no outcome, after which the loop emits `agent/inbox/claimed`. Every insertion emits `agent/inbox/inserted { message }`. `MessageId` stays unique across both pending lists, and synchronous durable-event observers can reconstruct removed values from the pre-splice projection. ### Loop lifecycle (`agent.ts`) @@ -75,7 +75,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy: - Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → definition-owned `finalizeContent` → `tools/result` pipeline; exact event signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md) -- Compaction: pressure on `agent/step`; canonical overflow repair on `agent/request-error` +- Compaction: pressure on `agent/pre-step`; canonical overflow repair on `agent/request-error` - Model-request recovery: `dsh-llm-retry` records and waits exact-provider normal or unbounded backoff on `agent/request-error`, emits non-surface `llm/retry` status, then returns a retry action - Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation - Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 232b15d91f..e837396355 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -53,11 +53,11 @@ interface Config { ### 包内部实体驱动器 -实体 `ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。 +实体 `ReactLoopAgent`、其 inbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。 -统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。block 必须通过 `discardClaimed` 选择是否丢弃本次提交的批次;之后到达的 next-step 输入和排队提示词会继续保持待处理,等待后续获准的提示词。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。 +统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`followup()` 追加到 `next-turn` FIFO 并唤醒驱动器,`steer()` 追加到 `next-step` inbox 并唤醒驱动器,`inject()` 则追加到同一个 `next-step` inbox,但不唤醒驱动器。在轮次边界,驱动器会原子领取待处理的 next-step 输入和一条排队提示词;在步骤之间则只领取 next-step 输入。领取通过纯删除 splice 移除批次,并针对每条消息发出 `agent/inbox/claimed { message, turn }`。随后 `agent/pre-step` 返回 reject,或返回拟进入步骤的完整消息。reject 后已领取批次保持已删除;领取后插入的输入仍等待后续处理,而空闲注入会一直等待,直到 follow-up 或 steering 唤醒驱动器。 -每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`;steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 +每次 inbox 变更都会先发布一条规范化的 `agent/inbox/spliced` 事件,再修改实时投影。因此,插入、编辑、移除、领取与取消都通过同一组标准 splice 坐标回放。普通删除携带 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`;领取使用不带 outcome 的纯删除,随后由循环发出 `agent/inbox/claimed`。每次插入都会发出 `agent/inbox/inserted { message }`。`MessageId` 在两个待处理列表之间保持唯一,同步持久事件观察方可以从 splice 前投影重建被移除的值。 ### 循环生命周期(`agent.ts`) @@ -75,7 +75,7 @@ interface Config { 超出「调用模型、运行工具、重复」的所有内容,都属于监听事件分类体系的插件: - 钩子与策略:相关的 `agent/*` 检查点,加上受守卫保护的 `tools/pre-execute` → `tools/execute` → `tools/post-execute` → 定义拥有的 `finalizeContent` → `tools/result` 流水线;确切事件签名与 mode 位于生成的[事件目录](../../../docs/cordis-catalog/events.md) -- 压缩(compaction):在 `agent/step` 上观测压力;在 `agent/request-error` 上进行规范的溢出修复 +- 压缩(compaction):在 `agent/pre-step` 上观测压力;在 `agent/request-error` 上进行规范的溢出修复 - 模型请求恢复:`dsh-llm-retry` 在 `agent/request-error` 上记录并等待按确切提供方配置的 normal 或无界退避,发出不进入表层的 `llm/retry` 状态,然后返回重试动作 - 沙箱、权限、计划模式:使用 `tools/pre-execute` 提供可扩展的拒绝/询问,使用 `tools.guard()` 提供单调拥有方策略,使用 `tools/post-execute` 处理结果决定,并使用 `tools/result` 进行最终观测 - subagent:在循环外部实现为 `ctx.subagents` 提供方;进程内提供方使用 `ctx.agents.create()` 和拥有的 `AgentHandle` 进行 teardown,而通用的 [`ctx.tasks`](../../tasks/tasks/) 与 [`dsh-tool-subagent`](../../subagent/tool-subagent/) 负责后台收集。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index ee096cd75c..d78ffbe41f 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -11,6 +11,7 @@ import type { AgentStatus, CancelOptions, InboxTarget, + PreStepDecision, RequestErrorAction, } from '@deepseek-ai/dsh-agent' import { Inbox, agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' @@ -36,11 +37,6 @@ type Phase = | { kind: 'collecting'; abort: AbortController; lastTurn: number } | { kind: 'running'; abort: AbortController; turn: number; step: number } -type Admission = - | { kind: 'empty' } - | { kind: 'admitted'; messages: UserMessage[] } - | { kind: 'blocked' } - type StepEndReason = Extract /** Remove adapter-derived values before plugins propose the next request config. */ @@ -71,7 +67,10 @@ export class ReactLoopAgent implements Agent { public readonly options: AgentOptions, public readonly session: Session, ) { - this.inbox = new Inbox(session) + this.inbox = new Inbox(session, { + inserted: (message) =>{ emitAgentEvent(loopCtx, this, 'agent/inbox/inserted', { message }) }, + discarded: (message) =>{ emitAgentEvent(loopCtx, this, 'agent/inbox/discarded', { message }) }, + }) const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 this.phase = { kind: 'idle', lastTurn } this.scope = createScope(loopCtx, this) @@ -93,7 +92,7 @@ export class ReactLoopAgent implements Agent { } send(message: UserMessage, target: InboxTarget, wakeup: boolean): void { - // Waking input cannot join an aborted admission or turn, so it starts the next turn. + // Waking input cannot join an aborted pre-step or turn, so it starts the next turn. const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted const resolvedTarget = wakingAfterAbort ? 'next-turn' : target this.inbox.splice(resolvedTarget, Infinity, 0, [message]) @@ -114,13 +113,13 @@ export class ReactLoopAgent implements Agent { cancel(cause: AgentCancelCause, options: CancelOptions = {}): void { if (!options.keepInbox) { - this.inbox.splice('next-step', 0, this.inbox.nextStep.length, [], 'canceled') - this.inbox.splice('next-turn', 0, this.inbox.nextTurn.length, [], 'canceled') + this.inbox.splice('next-step', 0, this.inbox.nextStep.length, []) + this.inbox.splice('next-turn', 0, this.inbox.nextTurn.length, []) } if (this.phase.kind !== 'idle') this.phase.abort.abort(cause) } - /** Reserve a driver before deferring idle admission. */ + /** Reserve a driver before deferring idle pre-step processing. */ private scheduleKick(): void { if (this.phase.kind !== 'idle') return const driver = Promise.withResolvers() @@ -158,33 +157,22 @@ export class ReactLoopAgent implements Agent { } } - private async admit(onTurnBoundary: boolean): Promise { - if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": admit outside running phase`) + private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise { + if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`) const signal = this.phase.abort.signal - const claimed = [...this.inbox.nextStep] - const outboxLength = claimed.length - const queued = onTurnBoundary ? this.inbox.nextTurn[0] : undefined - if (queued !== undefined) claimed.push(queued) - if (claimed.length === 0) return { kind: 'empty' } + const claimed = this.inbox.claim(target) + for (const message of claimed) { + emitAgentEvent(this.loopCtx, this, 'agent/inbox/claimed', { message, turn: position.turn }) + } const decision = await agentEvents(this.loopCtx, this).waterfall( - 'agent/prompt-submit', claimed, signal, - () => Promise.resolve({ kind: 'allow', messages: claimed }), + 'agent/pre-step', claimed, { ...position, signal }, + () => Promise.resolve({ kind: 'enter', messages: claimed }), ) signal.throwIfAborted() - if (decision.kind === 'allow') { - this.inbox.splice('next-step', 0, outboxLength, [], 'admitted') - if (queued !== undefined) this.inbox.splice('next-turn', 0, 1, [], 'admitted') - return { kind: 'admitted', messages: decision.messages } - } - if (decision.discardClaimed) { - this.inbox.splice('next-step', 0, outboxLength, [], 'canceled') - if (queued !== undefined) this.inbox.splice('next-turn', 0, 1, [], 'canceled') - } - this.cancel({ kind: 'hook', reason: decision.reason }, { keepInbox: true }) - return { kind: 'blocked' } + return decision } - /** Admitted input stays unowned until `turn/start` commits. */ + /** Claimed input stays unowned until `turn/start` commits. */ private async turn(): Promise { if (this.phase.kind === 'idle') { this.throwError(new Error(`agent "${this.id}": turn without driver reservation`)) @@ -195,10 +183,10 @@ export class ReactLoopAgent implements Agent { const phase = { kind: 'running' as const, abort, turn: lastTurn, step: 0 } this.setPhase(phase) signal.throwIfAborted() - let admission: Admission + let decision: PreStepDecision try { - admission = await this.admit(true) - if (admission.kind !== 'admitted') return false + decision = await this.preStep('next-turn', { turn: phase.turn + 1, step: 1 }) + if (decision.kind === 'reject') return false signal.throwIfAborted() } catch (error: unknown) { if (signal.aborted) throw error @@ -213,15 +201,13 @@ export class ReactLoopAgent implements Agent { let turnEnds: TurnEndReason | null = null try { while (true) { - if (admission.kind === 'admitted') { - for (const message of admission.messages) { - this.session.append('user/message', message, { surfaceOp: 'append' }) - } - } signal.throwIfAborted() const step = ++phase.step this.session.append('step/start', { turn, step }) try { + for (const message of decision.messages) { + this.session.append('user/message', message, { surfaceOp: 'append' }) + } turnEnds = await this.step() } finally { this.session.append('step/end', { turn, step }) @@ -231,13 +217,13 @@ export class ReactLoopAgent implements Agent { await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal) signal.throwIfAborted() } - admission = await this.admit(false) - if (admission.kind === 'blocked') { + decision = await this.preStep('next-step', { turn, step: phase.step + 1 }) + if (decision.kind === 'reject') { turnEnds = { kind: 'blocked' } return false } signal.throwIfAborted() - if (admission.kind === 'empty' && turnEnds) break + if (decision.messages.length === 0 && turnEnds) break } } catch (error: unknown) { if (signal.aborted) { @@ -264,8 +250,6 @@ export class ReactLoopAgent implements Agent { if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`) const { turn, step, abort: { signal } } = this.phase signal.throwIfAborted() - await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal) - signal.throwIfAborted() const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal)) signal.throwIfAborted() const system = renderPrompt(assembly) diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index 068c5c8c21..47082dac18 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -42,7 +42,7 @@ interface GroupOutcome { * Ordinary completion and abort commit started-call results in order. Abort * drains them, records synthetic results for unstarted calls, and returns with * the signal still aborted after accepting started-call context through the - * caller-supplied acceptor (the machine stages it on its outbox for the next + * caller-supplied acceptor (the machine stages it in its next-step inbox for the * step boundary). An internal scheduler failure stops new dispatches, drains * already-started dispatches, and rejects with the first failure without * fabricating tool results. diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 3f5cdac7c1..bbc3e548e2 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -153,7 +153,7 @@ describe('AgentLoop initiator scope', () => { const { ctx } = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('signal-owner'), { provider: 'mock', model: 'mock' }) let signals: AbortSignal[] = [] - let admissionSignals: AbortSignal[] = [] + let preStepSignals: AbortSignal[] = [] const capture = (signal: AbortSignal | undefined): void => { if (signal === undefined) throw new Error('turn seam omitted its explicit signal') expect(ctx.agents.requireInitiator()).toBe(agent) @@ -164,16 +164,13 @@ describe('AgentLoop initiator scope', () => { if (context.agent === agent) capture(context.signal) return next() }) - ctx.on('agent/prompt-submit', async (subject, _message, signal, next) => { + ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => { if (subject === agent) { expect(ctx.agents.requireInitiator()).toBe(agent) - admissionSignals.push(signal) + preStepSignals.push(signal) } return next() }) - ctx.on('agent/step', (subject, _turn, _step, signal) => { - if (subject === agent) capture(signal) - }) ctx.on('agent/request', async (subject, _turn, _step, signal, next) => { if (subject === agent) capture(signal) return next() @@ -197,19 +194,19 @@ describe('AgentLoop initiator scope', () => { const firstSignal = signals[0] expect(firstSignal).toBeDefined() expect(new Set([...signals, ...adapter.requests.slice(0, 2).map(request => request.signal!)])).toEqual(new Set([firstSignal])) - expect(admissionSignals).toHaveLength(1) - expect(admissionSignals[0]).toBe(firstSignal) + expect(preStepSignals).toHaveLength(2) + expect(new Set(preStepSignals)).toEqual(new Set([firstSignal])) signals = [] - admissionSignals = [] + preStepSignals = [] const secondIdle = waitForIdle(ctx, agent) send(agent, 'second') await secondIdle const secondSignal = signals[0] expect(secondSignal).toBeDefined() expect(new Set([...signals, adapter.requests[2]!.signal!])).toEqual(new Set([secondSignal])) - expect(admissionSignals).toHaveLength(1) - expect(admissionSignals[0]).toBe(secondSignal) + expect(preStepSignals).toHaveLength(1) + expect(preStepSignals[0]).toBe(secondSignal) expect(secondSignal).not.toBe(firstSignal) expect(ctx.agents.currentInitiator()).toBeUndefined() await ctx.fiber.dispose() diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 589f993c63..bd5f001d3d 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -50,7 +50,37 @@ describe('Agent', () => { .toEqual({ kind: 'plugin', plugin: '' }) }) - it('idle inject() rejects invalid input before append', async () => { + it('emits exact inserted, claimed, and discarded inbox messages', async () => { + const ctx = await harness(new MockAdapter([textResponse('ok')])) + const agent = ctx.agentLoop.create(SessionId('inbox-events'), { provider: 'mock', model: 'mock' }) + const inserted: unknown[] = [] + const claimed: unknown[] = [] + const discarded: unknown[] = [] + ctx.on('agent/inbox/inserted', (subject, event) => { + if (subject === agent) inserted.push(event) + }) + ctx.on('agent/inbox/claimed', (subject, event) => { + if (subject === agent) claimed.push(event) + }) + ctx.on('agent/inbox/discarded', (subject, event) => { + if (subject === agent) discarded.push(event) + }) + const context = createUserMessage({ + content: [{ type: 'text', text: 'discard me' }], + source: { kind: 'plugin', plugin: 'test' }, + }) + agent.inject(context) + agent.inbox.remove('next-step', context.id) + const prompt = createUserMessage({ content: [{ type: 'text', text: 'run' }], source: { kind: 'user' } }) + agent.followup(prompt) + await agent.whenIdle() + + expect(inserted).toEqual([{ message: context }, { message: prompt }]) + expect(discarded).toEqual([{ message: context }]) + expect(claimed).toEqual([{ message: prompt, turn: 1 }]) + }) + + it('idle inject() rejects invalid input before enqueue', async () => { const ctx = await harness(new MockAdapter([textResponse('ok')])) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 4461ec9cb7..8330926041 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -668,9 +668,8 @@ describe('Agent.cancel()', () => { }) it.each([ - 'prompt-submit', + 'pre-step', 'system-prompt', - 'step', 'request', 'stopping', 'tool', @@ -690,8 +689,8 @@ describe('Agent.cancel()', () => { } switch (stage) { - case 'prompt-submit': - ctx.on('agent/prompt-submit', async (subject, _message, signal, next) => { + case 'pre-step': + ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => { if (subject === agent) await blockUntilAbort(signal) return next() }) @@ -705,11 +704,6 @@ describe('Agent.cancel()', () => { return next() }) break - case 'step': - ctx.on('agent/step', async (subject, _turn, _step, signal) => { - if (subject === agent) await blockUntilAbort(signal) - }) - break case 'request': ctx.on('agent/request', async (subject, _turn, _step, signal, next) => { if (subject === agent) await blockUntilAbort(signal) @@ -741,7 +735,7 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await idle const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') - if (stage === 'prompt-submit') { + if (stage === 'pre-step') { expect(turnEnd).toBeUndefined() } else { expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } }) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 76bee1dd5b..d2e3659701 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -67,30 +67,30 @@ describe('addressable inbox operations', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('inbox-actions'), { provider: 'mock', model: 'mock' }) - const admission = Promise.withResolvers() + const preStep = Promise.withResolvers() const release = Promise.withResolvers() - ctx.on('agent/prompt-submit', async (_subject, messages, _signal, next) => { + ctx.on('agent/pre-step', async (_subject, messages, _signal, next) => { if (messages[0]?.content[0]?.type === 'text' && messages[0].content[0].text === 'first') { - admission.resolve(undefined) + preStep.resolve(undefined) await release.promise } return next() }) send(agent, 'first') - await admission.promise + await preStep.promise send(agent, 'remove me') send(agent, 'edit me') const pending = agent.inbox.nextTurn - expect(pending.map(inboxText)).toEqual(['first', 'remove me', 'edit me']) + expect(pending.map(inboxText)).toEqual(['remove me', 'edit me']) - const remove = pending[1]! - const edit = pending[2]! - expect(agent.inbox.splice('next-turn', 2, 1, [freezeMessage({ + const remove = pending[0]! + const edit = pending[1]! + expect(agent.inbox.splice('next-turn', 1, 1, [freezeMessage({ ...edit, content: [{ type: 'text', text: 'edited' }], })])).toEqual([edit]) - expect(agent.inbox.splice('next-turn', 1, 1, [])).toEqual([remove]) + expect(agent.inbox.splice('next-turn', 0, 1, [])).toEqual([remove]) const idle = waitForIdle(ctx, agent) release.resolve(undefined) @@ -316,11 +316,19 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'leave an unmatched historical call') await waitForIdle(ctx, agent) - const disposeInjection = ctx.on('agent/step', (subject, turn) => { - if (subject === agent && turn === 2) { + const disposeInjection = ctx.on('agent/pre-step', async (subject, _messages, { turn }, next) => { + const decision = await next() + if (subject === agent && turn === 2 && decision.kind === 'enter') { disposeInjection() - agent.inject(createUserMessage({ content: [{ type: 'text', text: 'new turn context' }], source: { kind: 'plugin', plugin: 'test' } })) + return { + kind: 'enter' as const, + messages: [...decision.messages, createUserMessage({ + content: [{ type: 'text', text: 'new turn context' }], + source: { kind: 'plugin', plugin: 'test' }, + })], + } } + return decision }) send(agent, 'start a text-only turn') await waitForIdle(ctx, agent) @@ -903,8 +911,7 @@ describe('turn and step boundary recovery', () => { expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false) }) - it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => { - // Disposal remains authoritative when the listener also throws. + it('contains a pre-step throw after disposal without opening a turn', async () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) let agent!: Agent @@ -913,8 +920,8 @@ describe('turn and step boundary recovery', () => { }, { inject: ['agentLoop'] })) let threw = false - ctx.on('agent/step', () => { - if (threw) return + ctx.on('agent/pre-step', (_subject, _messages, _context, next) => { + if (threw) return next() threw = true void fiber.dispose() throw new Error('boom pre-step during disposal') @@ -928,12 +935,9 @@ describe('turn and step boundary recovery', () => { await agent.whenIdle() const e = [...agent.session.events] - // Balanced: one turn/start, one turn/end carrying disposed (NOT error). - expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) - const turnEnd = e.findLast(x => x.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'disposed' } }) - expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false) - expect(e.some(x => x.type === 'step/start')).toBe(true) + expect(e.some(x => x.type === 'turn/start')).toBe(false) + expect(e.some(x => x.type === 'turn/end')).toBe(false) + expect(e.some(x => x.type === 'step/start')).toBe(false) expect(errorEmits).toHaveLength(0) }) @@ -1224,9 +1228,8 @@ describe('disposal and cancellation during pre-step assembly', () => { expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) }) - it('disposal during agent/step listeners ends the turn disposed', { timeout: 15000 }, async () => { - // Start disposal, then release pre-step; awaiting disposal first would - // deadlock on the blocked driver. + it('disposal during pre-step prevents the turn from opening', { timeout: 15000 }, async () => { + // Start disposal, then release pre-step; awaiting disposal first would deadlock on the blocked driver. const adapter = new MockAdapter(['hang']) let releasePreStep!: () => void const blocker = new Promise(r => void (releasePreStep = r)) @@ -1241,8 +1244,9 @@ describe('disposal and cancellation during pre-step assembly', () => { await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/step', async () => { + ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { await blocker + return next() }) let agent!: Agent @@ -1261,23 +1265,17 @@ describe('disposal and cancellation during pre-step assembly', () => { await disposalDone await driverDone(agent) - // After the agent/step listeners finish, the post-listener cancel/dispose - // check catches disposal before any LLM call. + // The post-listener cancellation check catches disposal before any turn or LLM call. const e = [...agent.session.events] - expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) - expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) - const turnEnd = e.findLast(x => x.type === 'turn/end') - // Disposal wins the post-listener check — reason is `disposed`. - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'disposed' } }) - expect(e.filter(x => x.type === 'step/start')).toHaveLength(1) - expect(e.filter(x => x.type === 'step/end')).toHaveLength(1) + expect(e.some(x => x.type === 'turn/start')).toBe(false) + expect(e.some(x => x.type === 'turn/end')).toBe(false) + expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) - // The durable turn/end record is the authoritative turn-boundary signal - // (turn boundaries have no agent/* mirror). + expect(reasons).toEqual([]) }) - it('cancel during agent/step listeners ends the turn aborted', { timeout: 15000 }, async () => { - // Release agent/step after cancellation to exercise the post-listener check. + it('cancel during pre-step prevents the turn from opening', { timeout: 15000 }, async () => { + // Release pre-step after cancellation to exercise the post-listener check. const adapter = new MockAdapter(['hang']) let releasePreStep!: () => void const blocker = new Promise(r => void (releasePreStep = r)) @@ -1292,8 +1290,9 @@ describe('disposal and cancellation during pre-step assembly', () => { await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/step', async () => { + ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { await blocker + return next() }) let agent!: Agent @@ -1314,14 +1313,11 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) const e = [...agent.session.events] - expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) - expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) - const turnEnd = e.findLast(x => x.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } }) - expect(e.filter(x => x.type === 'step/start')).toHaveLength(1) - expect(e.filter(x => x.type === 'step/end')).toHaveLength(1) + expect(e.some(x => x.type === 'turn/start')).toBe(false) + expect(e.some(x => x.type === 'turn/end')).toBe(false) + expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) - expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) + expect(reasons).toEqual([]) }) it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => { diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index c37779e8fb..4611a75cf9 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -11,7 +11,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, - type PromptDecision, + type PreStepDecision, type SessionStartSource, } from '@deepseek-ai/dsh-agent' @@ -19,7 +19,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' /** - * The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`, + * The interception seams introduced by the hooks taxonomy: `agent/pre-step`, * `agent/session-start`, `agent/turn-stopping`, and the * `tools/pre-execute` / `tools/post-execute` * split with `additionalContexts` buffering. These verify the canonical event @@ -58,14 +58,14 @@ function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } -describe('agent/prompt-submit', () => { - it('allow (default via next) records the user/message unchanged', async () => { +describe('agent/pre-step', () => { + it('enter (default via next) records the user/message unchanged', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const seen: string[] = [] - ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next) => { + ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { seen.push(messages[0]!.content.map(b => (b.type === 'text' ? b.text : '')).join('')) return next() }) @@ -78,15 +78,43 @@ describe('agent/prompt-submit', () => { expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'hello' }]) }) + it('reports the request coordinates for initial and tool-continuation prompts', async () => { + const adapter = new MockAdapter([ + toolCallResponse('c1', 'echo', { text: 'hi' }), + textResponse('done'), + ]) + const ctx = await harness(adapter) + ctx.tools.register(defineContentToolFixture({ + name: 'echo', + description: 'echo', + parameters: { text: { type: 'string', required: true } }, + execute: async ({ text }) => [{ type: 'text', text }], + })) + const agent = ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' }) + const seen: Array<{ turn: number; step: number; messages: number }> = [] + ctx.on('agent/pre-step', async (_agent, messages, context, next) => { + seen.push({ turn: context.turn, step: context.step, messages: messages.length }) + return next() + }) + + send(agent, 'hello') + await waitForIdle(ctx, agent) + + expect(seen).toEqual([ + { turn: 1, step: 1, messages: 1 }, + { turn: 1, step: 2, messages: 0 }, + ]) + }) + it('publishes frozen input without replacing its identity', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('owned-input'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() - const decision = Promise.withResolvers() + const decision = Promise.withResolvers() const observed: UserMessage[] = [] - ctx.on('agent/prompt-submit', async (subject, messages) => { - if (subject !== agent) return { kind: 'allow', messages } + ctx.on('agent/pre-step', async (subject, messages) => { + if (subject !== agent) return { kind: 'enter', messages } const message = messages[0]! expect(Object.isFrozen(message)).toBe(true) expect(Object.isFrozen(message.content)).toBe(true) @@ -115,7 +143,7 @@ describe('agent/prompt-submit', () => { expect(() => { if (input.source.kind === 'plugin') input.source.plugin = 'caller mutation' }).toThrow(TypeError) - decision.resolve({ kind: 'allow', messages: [input] }) + decision.resolve({ kind: 'enter', messages: [input] }) await idle expect(observed).toHaveLength(1) @@ -128,14 +156,14 @@ describe('agent/prompt-submit', () => { expect(userMsg?.type === 'user/message' && userMsg.data).toEqual(input) }) - it('allow with content REWRITES the prompt before it is recorded', async () => { + it('enter with content rewrites the prompt before it is recorded', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/prompt-submit', async (_agent, messages): Promise => + ctx.on('agent/pre-step', async (_agent, messages): Promise => ({ - kind: 'allow', + kind: 'enter', messages: [{ ...messages[0]!, content: [{ type: 'text', text: 'REWRITTEN' }] }], })) @@ -149,14 +177,14 @@ describe('agent/prompt-submit', () => { expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original') }) - it('allow with additionalContexts injects separate injected-context user messages into the turn', async () => { + it('enter with additional messages records separately sourced context in the turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/prompt-submit', async (_agent, messages): Promise => + ctx.on('agent/pre-step', async (_agent, messages): Promise => ({ - kind: 'allow', + kind: 'enter', messages: [...messages, createUserMessage({ content: [{ type: 'text', text: 'extra ctx' }], source: { kind: 'plugin', plugin: 'test' }, @@ -176,43 +204,12 @@ describe('agent/prompt-submit', () => { expect(sent).toContain('extra ctx') }) - it('runs pre-step after prompt rewrites and injected context become durable', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - - ctx.on('agent/prompt-submit', async (_agent, messages): Promise => - ({ - kind: 'allow', - messages: [{ - ...messages[0]!, - content: [{ type: 'text', text: 'REWRITTEN prompt' }], - }, createUserMessage({ - content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' }, - })], - })) - - let preStepDerived: string | undefined - ctx.on('agent/step', (subject, _turn, step) => { - if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages()) - }) - - send(agent, 'ORIGINAL prompt') - await waitForIdle(ctx, agent) - - expect(preStepDerived).toBeDefined() - expect(preStepDerived).toContain('REWRITTEN prompt') - expect(preStepDerived).toContain('injected ctx') - expect(preStepDerived).not.toContain('ORIGINAL prompt') - }) - - it('block drops the claimed prompt before any turn or model call', async () => { + it('reject drops the claimed prompt before any turn or model call', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/prompt-submit', async (): Promise => - ({ kind: 'block', reason: 'blocked by policy', discardClaimed: true })) + ctx.on('agent/pre-step', async (): Promise => ({ kind: 'reject' })) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -230,44 +227,24 @@ describe('agent/prompt-submit', () => { expect(reasons).toEqual([]) }) - it('block can retain the claimed prompt without opening a turn', async () => { - const adapter = new MockAdapter([]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('retained-claim'), { provider: 'mock', model: 'mock' }) - - ctx.on('agent/prompt-submit', async (): Promise => ({ - kind: 'block', - reason: 'try later', - discardClaimed: false, - })) - - send(agent, 'retained') - await agent.whenIdle() - - expect(agent.inbox.nextTurn.map(message => message.content[0])) - .toEqual([{ type: 'text', text: 'retained' }]) - expect(events(agent).some(event => event.type === 'turn/start')).toBe(false) - expect(adapter.requests).toEqual([]) - }) - - it('stages inject and steer during admission for the admitted turn', async () => { + it('stages inject and steer during pre-step for the entered turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('admission-outbox'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('pre-step-outbox'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() - const decision = Promise.withResolvers() + const decision = Promise.withResolvers() let claimed: UserMessage[] = [] - let firstAdmission = true - ctx.on('agent/prompt-submit', async (_agent, messages) => { - if (!firstAdmission) return { kind: 'allow', messages } - firstAdmission = false + let firstProposal = true + ctx.on('agent/pre-step', async (_agent, messages) => { + if (!firstProposal) return { kind: 'enter', messages } + firstProposal = false claimed = messages entered.resolve(undefined) return decision.promise }) const idle = waitForIdle(ctx, agent) - send(agent, 'admitted prompt') + send(agent, 'entered prompt') await entered.promise expect(agent.status).toBe('running') expect(events(agent).some(event => event.type === 'turn/start')).toBe(false) @@ -276,15 +253,15 @@ describe('agent/prompt-submit', () => { content: [{ type: 'text', text: 'attached context' }], source: { kind: 'plugin', plugin: 'test' }, })) - agent.steer(createUserMessage({ content: [{ type: 'text', text: 'admission steering' }], source: { kind: 'user' } })) + agent.steer(createUserMessage({ content: [{ type: 'text', text: 'pre-step steering' }], source: { kind: 'user' } })) expect(events(agent).some(event => event.type === 'user/message')).toBe(false) expect(agent.inbox.nextStep.map(message => message.content[0])) .toEqual([ { type: 'text', text: 'attached context' }, - { type: 'text', text: 'admission steering' }, + { type: 'text', text: 'pre-step steering' }, ]) - decision.resolve({ kind: 'allow', messages: claimed }) + decision.resolve({ kind: 'enter', messages: claimed }) await idle expect(agent.inbox.hasPending).toBe(false) @@ -297,27 +274,27 @@ describe('agent/prompt-submit', () => { 'user/message', ]) expect(staged[1]?.type === 'user/message' && staged[1].data.content) - .toEqual([{ type: 'text', text: 'admitted prompt' }]) + .toEqual([{ type: 'text', text: 'entered prompt' }]) expect(staged[2]?.type === 'user/message' && staged[2].data.content) .toEqual([{ type: 'text', text: 'attached context' }]) expect(staged[3]?.type === 'user/message' && staged[3].data.content) - .toEqual([{ type: 'text', text: 'admission steering' }]) + .toEqual([{ type: 'text', text: 'pre-step steering' }]) const firstRequest = JSON.stringify(adapter.requests[0]?.messages) - expect(firstRequest).toContain('admitted prompt') + expect(firstRequest).toContain('entered prompt') expect(firstRequest).not.toContain('attached context') - expect(firstRequest).not.toContain('admission steering') + expect(firstRequest).not.toContain('pre-step steering') const nextRequest = JSON.stringify(adapter.requests[1]?.messages) expect(nextRequest).toContain('attached context') - expect(nextRequest).toContain('admission steering') + expect(nextRequest).toContain('pre-step steering') }) it('preserves input staged after the blocked batch was claimed', async () => { const adapter = new MockAdapter([textResponse('retried')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('blocked-admission-outbox'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('blocked-pre-step-outbox'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() - const decision = Promise.withResolvers() - const disposeBlock = ctx.on('agent/prompt-submit', async () => { + const decision = Promise.withResolvers() + const disposeBlock = ctx.on('agent/pre-step', async () => { entered.resolve(undefined) return decision.promise }) @@ -330,7 +307,7 @@ describe('agent/prompt-submit', () => { source: { kind: 'plugin', plugin: 'test' }, })) agent.steer(createUserMessage({ content: [{ type: 'text', text: 'staged steering' }], source: { kind: 'user' } })) - decision.resolve({ kind: 'block', reason: 'policy', discardClaimed: true }) + decision.resolve({ kind: 'reject' }) await blockedIdle expect(agent.inbox.nextStep.map(message => message.content[0])) @@ -357,24 +334,24 @@ describe('agent/prompt-submit', () => { expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering') }) - it('preserves later queued work when an admission is blocked', async () => { + it('preserves later queued work when a step is rejected', async () => { const adapter = new MockAdapter([ textResponse('continued'), textResponse('wake reply'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('rejected-admission-order'), { + const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-order'), { provider: 'mock', model: 'mock', }) - ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next) => { + ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { const decision = await next() return messages.some(message => message.content.some(block => block.type === 'text' && block.text === 'blocked prompt')) - ? { kind: 'block', reason: 'policy', discardClaimed: true } + ? { kind: 'reject' as const } : decision }) - ctx.on('agent/prompt-submit', async (subject, messages, _signal, next) => { + ctx.on('agent/pre-step', async (subject, messages, _signal, next) => { if (messages.some(message => message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))) { subject.inject(createUserMessage({ @@ -414,13 +391,13 @@ describe('agent/prompt-submit', () => { expect(request).not.toContain('blocked prompt') }) - it('preserves context-only injection staged after admission began', async () => { + it('preserves context-only injection staged after pre-step began', async () => { const adapter = new MockAdapter([textResponse('continued')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('blocked-admission-context'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-context'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() - const decision = Promise.withResolvers() - const disposeBlock = ctx.on('agent/prompt-submit', async () => { + const decision = Promise.withResolvers() + const disposeBlock = ctx.on('agent/pre-step', async () => { entered.resolve(undefined) return decision.promise }) @@ -432,7 +409,7 @@ describe('agent/prompt-submit', () => { content: [{ type: 'text', text: 'independent context' }], source: { kind: 'plugin', plugin: 'test' }, })) - decision.resolve({ kind: 'block', reason: 'policy', discardClaimed: true }) + decision.resolve({ kind: 'reject' }) await idle const log = events(agent) @@ -452,7 +429,7 @@ describe('agent/prompt-submit', () => { it('leaves inbox state unchanged when its durable append fails', async () => { const adapter = new MockAdapter([]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('blocked-admission-append-failure'), { + const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-append-failure'), { provider: 'mock', model: 'mock', }) @@ -476,11 +453,11 @@ describe('agent/prompt-submit', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next): Promise => { + ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise => { const text = messages.flatMap(message => message.content) .map(b => (b.type === 'text' ? b.text : '')).join('') return text === 'secret' - ? { kind: 'block', reason: 'policy: no secrets', discardClaimed: true } + ? { kind: 'reject' } : next() }) @@ -506,15 +483,15 @@ describe('agent/prompt-submit', () => { expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('secret') }) - it('a throwing prompt-submit listener reports the driver error and retains adjacent work', async () => { + it('a throwing pre-step listener reports the driver error and retains adjacent work', async () => { const adapter = new MockAdapter([textResponse('after')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threw = false - ctx.on('agent/prompt-submit', async (_agent, messages) => { + ctx.on('agent/pre-step', async (_agent, messages) => { if (!threw) { threw = true; throw new Error('prompt hook broke') } - return { kind: 'allow' as const, messages } + return { kind: 'enter' as const, messages } }) const errors: Error[] = [] const reasons: TurnEndReason[] = [] @@ -538,7 +515,8 @@ describe('agent/prompt-submit', () => { expect(reasons).toEqual([]) expect(statuses).toEqual(['running', 'idle']) expect(adapter.requests).toHaveLength(0) - expect(agent.inbox.nextTurn).toHaveLength(2) + expect(agent.inbox.nextTurn.map(message => message.content[0])) + .toEqual([{ type: 'text', text: 'second' }]) }) }) @@ -716,16 +694,12 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se ctx.on('agent/session-start', (agent, source) => { agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } })) }) - // 2. PromptSubmit: block a forbidden prompt, annotate the rest. - ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next): Promise => { + // 2. PreStep: reject a forbidden prompt, annotate the rest. + ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise => { const text = messages.flatMap(message => message.content) .map(b => (b.type === 'text' ? b.text : '')).join('') if (text.includes('rm -rf')) { - return { - kind: 'block', - reason: 'destructive prompt blocked', - discardClaimed: true, - } + return { kind: 'reject' } } return next() }) @@ -800,7 +774,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se const agent = ctx.agentLoop.create(SessionId('a3'), { provider: 'mock', model: 'mock' }) send(agent, 'run rm -rf /') await waitForIdle(ctx, agent) - // the prompt ran (not rejected) — proving the prompt-submit listener was disposed + // the prompt ran (not rejected) — proving the pre-step listener was disposed expect(adapter.requests).toHaveLength(1) expect(events(agent).some(e => e.type === 'user/message')).toBe(true) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index a0b7f1d935..d8aa8dddcd 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -94,7 +94,7 @@ describe('agent loop', () => { expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) const types = agent.session.events.map(e => e.type) - // Durable inbox receipt and admission bracket the turn-owned transcript. + // Durable inbox receipt precedes the turn-owned transcript. expect(types[0]).toBe('agent/inbox/spliced') expect(types).toContain('turn/start') expect(types).toContain('user/message') @@ -313,7 +313,7 @@ describe('agent loop', () => { const steering = agent.session.events.find(e => e.type === 'user/message' && JSON.stringify(e.data.content).includes('change of plans')) expect(steering).toBeDefined() - // Steering is admitted before the second step's request derives history. + // Steering enters history before the second step's request derives it. const steeringSeq = steering!.seq const secondStepStart = agent.session.events.filter(e => e.type === 'step/start')[1] expect(secondStepStart).toBeDefined() @@ -348,30 +348,30 @@ describe('agent loop', () => { expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('second idle steer') }) - it('stops after a throwing step observer and retains steering until a later wakeup', async () => { + it('stops after a throwing pre-step listener and retains later steering until a wakeup', async () => { const adapter = new MockAdapter([textResponse('recovered')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' }) let fail = true - ctx.on('agent/step', (subject) => { - if (subject !== agent || !fail) return + ctx.on('agent/pre-step', (subject, _messages, _context, next) => { + if (subject !== agent || !fail) return next() fail = false subject.steer(createUserMessage({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } })) - throw new Error('step failed') + throw new Error('pre-step failed') }) send(agent, 'prompt') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0) expect(agent.inbox.nextStep).toHaveLength(1) send(agent, 'resume') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false) expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering') }) @@ -603,7 +603,7 @@ describe('agent loop', () => { expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config.model).toBe('other-model') }) - it('agent/step fires once per step before the step is opened', async () => { + it('agent/pre-step fires once per proposed step before the step is opened', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'echo', {}, 'calling echo'), textResponse('done'), @@ -616,8 +616,9 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const fires: { turn: number; step: number; signal: AbortSignal }[] = [] - ctx.on('agent/step', (subject, turn, step, signal) => { + ctx.on('agent/pre-step', (subject, _messages, { turn, step, signal }, next) => { if (subject === agent) fires.push({ turn, step, signal }) + return next() }) send(agent, 'go') @@ -630,33 +631,33 @@ describe('agent loop', () => { expect(fires.every(({ signal }) => signal instanceof AbortSignal)).toBe(true) }) - it('agent/step fires after its step boundary opens and before the request', async () => { + it('agent/pre-step fires before its step boundary opens and before the request', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - let boundaryOpen = false - ctx.on('agent/step', (subject) => { + let boundaryOpen = true + ctx.on('agent/pre-step', (subject, _messages, _context, next) => { if (subject === agent) boundaryOpen = subject.session.events.at(-1)?.type === 'step/start' + return next() }) send(agent, 'go') await waitForIdle(ctx, agent) - expect(boundaryOpen).toBe(true) + expect(boundaryOpen).toBe(false) expect(adapter.requests).toHaveLength(1) }) - it('a throwing agent/step listener ends the turn (error), not the loop', async () => { - // Before step/start, a pre-step throw reaches the turn catch: no step needs - // closing, the turn records error, and the loop remains available. + it('a throwing agent/pre-step listener fails the proposal, not the loop', async () => { const adapter = new MockAdapter([textResponse('second turn ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let throwOnce = true - ctx.on('agent/step', () => { + ctx.on('agent/pre-step', (_agent, _messages, _context, next) => { if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') } + return next() }) const errors: Error[] = [] @@ -666,14 +667,11 @@ describe('agent loop', () => { send(agent, 'first') await waitForIdle(ctx, agent) - // The first turn failed at step 1 before a model call. + // The first proposal failed before opening a turn or calling the model. expect(errors.map(error => error.message)).toEqual(['boom in pre-step']) expect(adapter.requests.length).toBe(0) - const firstTurnEnd = agent.session.events.find(e => e.type === 'turn/end') - expect(firstTurnEnd?.type === 'turn/end' && firstTurnEnd.data.reason).toMatchObject({ kind: 'error' }) - // The step opened-and-closed count stays balanced even though it never ran. - const types = agent.session.events.map(e => e.type) - expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) + expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(agent.session.events.some(event => event.type === 'turn/end')).toBe(false) // The loop survived: a second prompt runs a normal completed turn. send(agent, 'second') diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 430d5eb273..0b937a53d2 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -532,7 +532,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx2.fiber.dispose() }) - it('an idle inject() survives persist + resume without a synthetic turn', async () => { + it('a pending idle inject() survives persist + resume without a synthetic turn', async () => { const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 315d7f5b6f..024d49787a 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -1066,7 +1066,7 @@ describe('agent scope lifecycle', () => { await waitForIdle(ctx, agent) expect(reentered).toBe(true) - // Idle again: the reentrant admission was already claimed and settled (its + // Idle again: the reentrant batch was already claimed and settled (its // prompt was blocked by nothing, so it ran) — arm a SECOND reentry that // fires from the disposal cancel's idle transition itself. reentered = false diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index ffd0285806..2327400ca6 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: b21ec0dc49604a91e4b8b34b0f47ccc01d897e88 -README.zh.md: 2cf6960310af0524d3e0f10c2cd3cf97fbf1869f +README.md: 8942dcd976f9c7c5a8109ad3979fefe2a913cba5 +README.zh.md: 274a37556a9c2af140c57e5346da8abf7bc49bf3 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 69b1724fdf..8942dcd976 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -50,9 +50,11 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. +Most interception points are cooperative waterfalls. `agent/pre-step` receives the exclusive claimed `UserMessage[]` plus a `PreStepContext` containing the proposed `turn`, `step`, and cancellation `signal`; its batch may be empty when tools already require another request. Other turn-scoped asynchronous seams receive their explicit `AbortSignal` positionally. Listeners may cooperate with a signal but must not retain it as authority over another turn. `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery. `agent/turn-stopping` runs before an otherwise completed turn closes. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. -`PromptDecision.allow.messages` is the complete identified, frozen batch admitted by prompt interception. A listener that wraps a downstream allow preserves that batch unless it intentionally replaces it. A block must choose `discardClaimed`; this affects only the submitted batch, while messages not claimed by that admission remain pending. +`PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages }`. The enter branch is the complete identified, frozen batch for the proposed step. A listener that wraps downstream entry preserves that batch unless it intentionally replaces it; additions follow the waterfall's natural return order. Claiming already removed the offered messages from the inbox, so rejection does not retain them. Messages inserted after the claim remain pending for a later boundary. + +Inbox live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`. They complement the durable `agent/inbox/spliced` projection without adding another lifecycle envelope. Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). @@ -60,10 +62,10 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values; `splice(target, start, deleteCount, inserted, outcome?)` uses standard splice coordinates to insert, edit, remove, admit, or cancel them. `MessageId` is the only occurrence identity and must remain unique while pending. -- `agent.followup(message)` — queue an ordinary `next-turn` message and wake the driver. It returns no completion handle; the message id identifies inbox and admission facts, not a later output or `turn/end`. +- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values. `append`, `prepend`, `update`, `remove`, and `splice` mutate them; ordinary removals are durable cancellations and emit `agent/inbox/discarded`. `claim(target)` atomically removes the next proposed batch with pure deletion splices; the loop then emits `agent/inbox/claimed`. `MessageId` is the only occurrence identity and must remain unique while pending. +- `agent.followup(message)` — queue an ordinary `next-turn` message and wake the driver. It returns no completion handle; the message id identifies inbox insertion, claim, and discard facts, not a later output or `turn/end`. - `agent.steer(message)` — queue waking `next-step` input. An idle driver schedules a turn; collecting and running drivers consume it at their next step boundary. -- `agent.inject(message)` — queue non-waking `next-step` context. During admission or an open turn it waits for the next safe log position; otherwise it appends immediately without opening a turn. +- `agent.inject(message)` — queue non-waking `next-step` context. A collecting or running driver claims it at the nearest later pre-step boundary; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. It may miss a request whose pre-step already claimed its batch. - `agent.cancel(cause, options?)` — cancel the active driver and, unless `options.keepInbox`, durably cancel all pending inbox work. Idle cancellation is a no-op. - `agent.whenIdle()` — observe whole-agent quiescence, including replacement work scheduled before the current driver retires. It does not settle any particular message. - `agent.session`, `agent.status`, `agent.options`, `agent.id`, `agent.ctx` @@ -82,7 +84,7 @@ The handle every plugin programs against: #### What the model sees -`send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/step`, and other declared events let plugins block a prompt or add durable request material; this interface contributes no fixed prose itself. +`send`, `steer`, and `inject` feed the owning session. `agent/pre-step` and other declared events let plugins reject a proposed step or add durable request material; this interface contributes no fixed prose itself. #### Token effect diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 8517fcfa41..274a37556a 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -50,9 +50,11 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器完全停稳后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。 -大多数拦截点都是协作式 waterfall(瀑布式事件)。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时提供服务的注册项重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 +大多数拦截点都是协作式 waterfall(瀑布式事件)。`agent/pre-step` 接收独占的已领取 `UserMessage[]`,以及包含拟进入 `turn`、`step` 与取消 `signal` 的 `PreStepContext`;当工具已经要求继续请求时,该批次可以为空。其他轮次作用域异步 seam 仍按位置接收显式 `AbortSignal`。监听器可以配合信号,但不得将它保留为控制另一轮次的权限。`agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时提供服务的注册项重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`。`agent/turn-stopping` 在本可完成的轮次关闭前运行。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 -`PromptDecision.allow.messages` 是提示词拦截所准入的完整、带标识且冻结的批次。包装下游 allow 的监听器会保留该批次,除非有意替换它。block 必须指定 `discardClaimed`;该字段仅影响本次提交的批次,未被此次接纳认领的消息会继续保持待处理。 +`PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages }`。enter 分支是拟进入步骤的完整、带标识且冻结的批次。包装下游 enter 的监听器会保留该批次,除非有意替换它;新增消息遵循 waterfall 的自然返回顺序。领取操作已经把候选消息从 inbox 删除,因此 reject 不会保留它们;领取后插入的消息仍等待后续边界。 + +inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }`、`agent/inbox/claimed { message, turn }` 与 `agent/inbox/discarded { message }`。它们补充持久 `agent/inbox/spliced` 投影,但不引入另一层生命周期封套。 轮次和步骤边界以及模型 token 流是持久 `session/event` 事实,而不是镜像的 `agent/*` 通知。消费方从会话事件流读取 `turn/*`、`step/*` 和 `assistant/chunk`;工具策略与结果观测属于 [`dsh-tools`](../tools/README.md) 记录的完整流水线。 @@ -60,10 +62,10 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 每个插件面向的 handle: -- `agent.inbox`:agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn` 与 `nextStep` 暴露待处理的 `UserMessage` 值;`splice(target, start, deleteCount, inserted, outcome?)` 使用标准 splice 坐标插入、编辑、移除、准入或取消消息。`MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。 -- `agent.followup(message)`:将一条普通 `next-turn` 消息排队并唤醒驱动器。它不返回完成 handle;消息 id 标识 inbox 和准入事实,而不标识之后的输出或 `turn/end`。 +- `agent.inbox`:agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn` 与 `nextStep` 暴露待处理的 `UserMessage` 值。`append`、`prepend`、`update`、`remove` 与 `splice` 用于变更队列;普通删除是持久取消,并发出 `agent/inbox/discarded`。`claim(target)` 通过纯删除 splice 原子移除下一个候选批次,随后由循环发出 `agent/inbox/claimed`。`MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。 +- `agent.followup(message)`:将一条普通 `next-turn` 消息排队并唤醒驱动器。它不返回完成 handle;消息 id 标识 inbox 的插入、领取与丢弃事实,而不标识之后的输出或 `turn/end`。 - `agent.steer(message)`:将会唤醒的 `next-step` 输入排队。空闲驱动器会调度一个轮次;collecting 和 running 驱动器会在各自的下一步骤边界消费该输入。 -- `agent.inject(message)`:将不会唤醒的 `next-step` 上下文排队。在准入期间或轮次打开时,它会等待下一个安全日志位置;否则立即追加,且不打开轮次。 +- `agent.inject(message)`:将不会唤醒的 `next-step` 上下文排队。collecting 或 running 驱动器会在最近的后续 pre-step 边界领取它;idle 驱动器则会让它保持待处理,直至 `followup()` 或 `steer()` 唤醒驱动器。若某次请求的 pre-step 已经领取完批次,它可能赶不上该请求。 - `agent.cancel(cause, options?)`:取消活跃驱动器,并在未设置 `options.keepInbox` 时持久取消全部待处理 inbox 工作。空闲取消是空操作。 - `agent.whenIdle()`:观察整个 agent 达到完全停稳,包括当前驱动器退役前调度的替代工作。它不结算任何特定消息。 - `agent.session`、`agent.status`、`agent.options`、`agent.id`、`agent.ctx` @@ -82,7 +84,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, #### 模型看到的内容 -`send`、`steer` 与 `inject` 会向所属会话提供输入。`agent/prompt-submit`、`agent/step` 和其他已声明事件让插件能够阻止提示词或添加持久请求材料;此接口本身不贡献固定文案。 +`send`、`steer` 与 `inject` 会向所属会话提供输入。`agent/pre-step` 和其他已声明事件让插件能够拒绝拟进入的步骤或添加持久请求材料;此接口本身不贡献固定文案。 #### Token 影响 diff --git a/packages/core/agent/src/inbox.ts b/packages/core/agent/src/inbox.ts index 2cdacfddfd..01bb24dd45 100644 --- a/packages/core/agent/src/inbox.ts +++ b/packages/core/agent/src/inbox.ts @@ -4,6 +4,7 @@ * @module @deepseek-ai/dsh-agent/inbox */ +import type { MessageId } from '@deepseek-ai/dsh-llm' import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session' /** One of the two ordered pending-message lists owned by an agent. */ @@ -12,11 +13,22 @@ export type InboxTarget = 'next-turn' | 'next-step' /** Mutable state privately owned by an {@link Inbox}. */ type InboxState = Record +/** Live notifications committed by inbox mutations. */ +export interface InboxNotifications { + /** Publish one inserted message. */ + inserted(message: UserMessage): void + /** Publish one discarded message. */ + discarded(message: UserMessage): void +} + /** A replay-once projection that incrementally consumes later inbox splices. */ export class Inbox { private readonly state: InboxState = { 'next-turn': [], 'next-step': [] } - constructor(private readonly session: Session) { + constructor( + private readonly session: Session, + private readonly notifications: InboxNotifications, + ) { for (const event of session.events.slice(session.header.seedLength ?? 0)) { if (event.type !== 'agent/inbox/spliced') continue try { @@ -32,7 +44,7 @@ export class Inbox { return this.state['next-turn'] } - /** Input awaiting admission at a step boundary. */ + /** Input awaiting the next step boundary. */ get nextStep(): readonly UserMessage[] { return this.state['next-step'] } @@ -42,6 +54,68 @@ export class Inbox { return this.nextTurn.length > 0 || this.nextStep.length > 0 } + /** + * Remove and return the complete batch proposed for one step. The durable + * splices are pure deletions; the caller publishes claimed notifications. + * @param target - whether this boundary also consumes one queued turn. + * @returns next-step input followed by the queued turn, when requested. + */ + claim(target: InboxTarget): UserMessage[] { + const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false) + if (target === 'next-turn') { + claimed.push(...this.mutate('next-turn', 0, 1, [], false)) + } + return claimed + } + + /** + * Append one message to a pending list and durably record the insertion. + * @param target - pending list to extend. + * @param message - message to append. + * @throws if the message identity is already pending. + */ + append(target: InboxTarget, message: UserMessage): void { + this.splice(target, this.state[target].length, 0, [message]) + } + + /** + * Prepend one message to a pending list and durably record the insertion. + * @param target - pending list to extend. + * @param message - message to prepend. + * @throws if the message identity is already pending. + */ + prepend(target: InboxTarget, message: UserMessage): void { + this.splice(target, 0, 0, [message]) + } + + /** + * Replace one pending message in place and durably record the mutation. + * @param target - pending list containing the message. + * @param messageId - identity of the message to replace. + * @param newMessage - replacement message. + * @returns whether the message was still pending. + * @throws if the replacement duplicates another pending message identity. + */ + update(target: InboxTarget, messageId: MessageId, newMessage: UserMessage): boolean { + const index = this.state[target].findIndex(message => message.id === messageId) + if (index < 0) return false + this.splice(target, index, 1, [newMessage]) + return true + } + + /** + * Remove one pending message and durably record its cancellation. + * @param target - pending list containing the message. + * @param messageId - identity of the message to remove. + * @returns whether the message was still pending. + */ + remove(target: InboxTarget, messageId: MessageId): boolean { + const index = this.state[target].findIndex(message => message.id === messageId) + if (index < 0) return false + this.splice(target, index, 1, []) + return true + } + /** * Apply standard splice semantics and durably record the normalized result. * The durable event commits before the live projection mutates, so synchronous @@ -51,7 +125,6 @@ export class Inbox { * @param start - splice position. * @param deleteCount - maximum number of messages to remove. * @param inserted - messages to insert at the resolved position. - * @param outcome - terminal disposition of removed messages. * @returns messages removed by the splice. */ splice( @@ -59,7 +132,17 @@ export class Inbox { start: number, deleteCount: number, inserted: UserMessage[], - outcome?: 'admitted' | 'canceled', + ): UserMessage[] { + return this.mutate(target, start, deleteCount, inserted, true) + } + + /** Commit one normalized mutation and publish its live notifications. */ + private mutate( + target: InboxTarget, + start: number, + deleteCount: number, + inserted: UserMessage[], + discardRemoved: boolean, ): UserMessage[] { const inbox = this.state[target] const truncatedStart = Math.trunc(start) @@ -73,17 +156,22 @@ export class Inbox { inbox.length - actualStart, ) if (actualDeleteCount === 0 && inserted.length === 0) return [] - const resolvedOutcome = outcome ?? (actualDeleteCount > 0 ? 'canceled' : undefined) + const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' : undefined const splice = { target, start: actualStart, ...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }), inserted, - ...(resolvedOutcome === undefined ? {} : { outcome: resolvedOutcome }), + ...(outcome === undefined ? {} : { outcome }), } this.validate(splice) const event = this.session.append('agent/inbox/spliced', splice) - return inbox.splice(actualStart, actualDeleteCount, ...event.data.inserted) + const removed = inbox.splice(actualStart, actualDeleteCount, ...event.data.inserted) + if (discardRemoved) { + for (const message of removed) this.notifications.discarded(message) + } + for (const message of event.data.inserted) this.notifications.inserted(message) + return removed } /** Apply one normalized durable splice to the projection. */ diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 33f6c9ca26..fd33c256d1 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -42,21 +42,26 @@ export interface CancelOptions { /** * An agent's lifecycle state, emitted on every transition as `agent/status`: * `idle` means no driver is scheduled or active; `running` begins when a - * cancellable admission is scheduled and lasts while the driver drains, + * cancellable pre-step processing is scheduled and lasts while the driver drains, * closes, or checkpoints turns. Disposal removes the agent from its registry; * it is not a third observable status. */ export type AgentStatus = 'idle' | 'running' -/** - * Prompt interception result. An allowed batch replaces the submitted - * messages; a listener wrapping `next()` preserves that batch unless it - * intentionally replaces it. A blocked batch explicitly chooses whether to - * discard the claimed messages; unclaimed work remains pending. - */ -export type PromptDecision = - | { kind: 'allow'; messages: UserMessage[] } - | { kind: 'block'; reason: string; discardClaimed: boolean } +/** Coordinates and cancellation for a proposed step. */ +export interface PreStepContext { + /** Turn that will own the step. */ + readonly turn: number + /** Step proposed by the loop. */ + readonly step: number + /** Current turn cancellation signal. */ + readonly signal: AbortSignal +} + +/** Whether and with which messages the loop enters a proposed step. */ +export type PreStepDecision = + | { kind: 'reject' } + | { kind: 'enter'; messages: UserMessage[] } /** One failed model-request attempt presented to recovery listeners. */ export interface RequestFailureContext { @@ -135,11 +140,11 @@ export interface Agent { steer(message: UserMessage): void /** - * Append model-facing context without running the model. Admission or an - * open turn stages it at the next safe log position; outside that window it - * appends immediately without opening a turn. If admission closes without a - * turn, a context-only boundary appends immediately; context staged beside - * steering remains pending with it. + * Queue model-facing context for the next pre-step without waking the + * driver. Collecting and running drivers claim it at the nearest later + * step boundary; idle drivers leave it pending until follow-up or steering + * wakes them. It may miss a request whose pre-step already claimed its + * batch. Cancellation or disposal may discard pending context. * @param message - identified injected context and its producer provenance. */ inject(message: UserMessage): void @@ -178,6 +183,30 @@ declare module 'cordis' { * @mode emit */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void + /** + * One message entered the live inbox. + * @param agent - the agent whose inbox changed. + * @param event - the inserted message. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/inbox/inserted'(this: Scoped, agent: Agent, event: { message: UserMessage }): void + /** + * One message left the inbox for a turn. + * @param agent - the agent whose inbox changed. + * @param event - the claimed message and owning turn. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/inbox/claimed'(this: Scoped, agent: Agent, event: { message: UserMessage; turn: number }): void + /** + * One message was discarded from the live inbox. + * @param agent - the agent whose inbox changed. + * @param event - the discarded message. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/inbox/discarded'(this: Scoped, agent: Agent, event: { message: UserMessage }): void // ---- session lifecycle (emit) ---- /** * The session lifecycle began, once before the first turn. Use @@ -193,30 +222,15 @@ declare module 'cordis' { // ---- the machine's extension seams ---- /** - * Allow, rewrite, or block one claimed inbox batch before it becomes - * model-visible or opens a turn. Call `next()` for the unchanged default. The - * signal controls only this admission attempt; listeners may cooperate with - * it but must not retain it for a later attempt or turn. - * @param agent - the agent whose driver claimed the batch. - * @param messages - the claimed messages. - * @param signal - the current turn's explicit abort signal. + * Reject a proposed step or replace the messages that enter it. Calling + * `next()` preserves the current messages. + * @param agent - the agent proposing the step. + * @param messages - messages removed from the inbox for this step. + * @param context - proposed turn and step coordinates plus cancellation. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/prompt-submit'(this: Scoped, agent: Agent, messages: UserMessage[], signal: AbortSignal, next: () => Promise): Promise - /** - * Awaited serial checkpoint before EVERY request of a turn is built (the - * first as well as each post-tools continuation). The single "between - * steps" extension point: inject context, steer, or edit the session log - * here — the request's history derives from the log right after this settles. - * @param agent - the agent about to send a request. - * @param turn - the open turn number. - * @param step - the step number about to open. - * @param signal - the turn abort signal. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode serial - */ - 'agent/step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void + 'agent/pre-step'(this: Scoped, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise): Promise /** * Replace the frozen call configuration. `await next()` yields the config * the machine would use (agent options on the first request, the logged @@ -284,7 +298,7 @@ declare module '@deepseek-ai/dsh-session' { start: number removedCount?: number inserted: UserMessage[] - outcome?: 'admitted' | 'canceled' + outcome?: 'canceled' } } } diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 7d9b4399fa..2445be776b 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -21,7 +21,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { id, options: {}, session, - inbox: new Inbox(session), + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx: new Context(), send: () => {}, diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index 836294c93d..e544c47987 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -11,12 +11,14 @@ const scopedSubjectResolvers: Readonly args[0], 'agent/disposed': args => args[0], 'agent/error': args => args[0], - 'agent/prompt-submit': args => args[0], + 'agent/inbox/claimed': args => args[0], + 'agent/inbox/discarded': args => args[0], + 'agent/inbox/inserted': args => args[0], + 'agent/pre-step': args => args[0], 'agent/request': args => args[0], 'agent/request-error': args => args[0], 'agent/session-start': args => args[0], 'agent/status': args => args[0], - 'agent/step': args => args[0], 'agent/turn-stopping': args => args[0], 'approval/request': args => (args[0] as Record)['agent'], 'goal/changed': args => args[0], diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index 35f3455a85..d647344537 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -48,9 +48,11 @@ describe('scoped-dispatch invariants', () => { 'agent/created': [agent], 'agent/disposed': [agent], 'agent/status': [agent, 'idle'], + 'agent/inbox/inserted': [agent, { message }], + 'agent/inbox/claimed': [agent, { message, turn: 1 }], + 'agent/inbox/discarded': [agent, { message }], 'agent/session-start': [agent, 'startup'], - 'agent/step': [agent, 1, 1, signal], - 'agent/prompt-submit': [agent, [message], signal, () => Promise.resolve({ kind: 'allow', messages: [message] })], + 'agent/pre-step': [agent, [message], { turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [message] })], 'agent/request': [agent, 1, 1, signal, () => Promise.resolve(config)], 'agent/request-error': [ agent, diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 8637027110..1967115a4d 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: d2eb35108b7eba9963e62a1c0d2a8868c7b7a821 -README.zh.md: 85507abf55e82485268055f99f759b75f8cc5f01 +README.md: 2928cd22d7ecafdca951c3b9aca1fb0bdc2567dd +README.zh.md: 209219e98b7dab7f3f142ccb0069f5a7eb654124 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index d2eb35108b..2928cd22d7 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -65,7 +65,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/ `request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. Its optional `adapterDefaults` map marks effective `reasoningEffort` or `maxTokens` values materialized by exact-model resolution, allowing the next request proposal to distinguish them from explicit conversation settings. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -A `user/message` stores the complete `UserMessage` directly, including the identity created before routing or prompt admission. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an admitted goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message`, `tool/result`, and `steering/message` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`, while an idle injection may append and flush a `user/message` between turns without running the model. +A `user/message` stores the complete `UserMessage` directly, including the identity created before inbox routing or step entry. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an entered goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message`, `tool/result`, and `steering/message` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`; `agent.inject()` queues input until a later pre-step claims it and returns it in an enter decision. `tool/result` persists one identified user-role tool-result message, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message. @@ -75,7 +75,7 @@ The append-only log's event types, enumerated member by member — payloads, sur Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. A plugin owns the relational invariant for its merged events, including whether a log-only event may appear between turns. A producer that requires durability appends through `Session` and then awaits `ctx.sessions.flush(session)` without fabricating an execution turn. -Also defines `TurnEndReasonMap`, the merge-extensible `kind`-tagged sum type for turn endings. `turn/start` carries only the turn number; the following admitted `user/message` batch records its input, while `llm/retry` records request recovery. +Also defines `TurnEndReasonMap`, the merge-extensible `kind`-tagged sum type for turn endings. `turn/start` carries only the turn number; the following entered `user/message` batch records its input, while `llm/retry` records request recovery. An interrupted live turn ends with `{ kind: 'aborted', reason: AgentCancelCause }`, preserving the typed cancellation cause in the durable transcript. A turn failure carries `{ kind: 'error', error }`; crash recovery alone synthesizes `{ kind: 'interrupted' }`. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 85507abf55..209219e98b 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -65,7 +65,7 @@ `request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial`、`resume` 或 `change`。其可选 `adapterDefaults` 映射会标记由精确模型解析填入的生效 `reasoningEffort` 或 `maxTokens` 值,使下一次请求提议能够将它们与显式对话设置区分开。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 -`user/message` 会直接存储完整的 `UserMessage`,其中包括路由或提示词准入前创建的标识。无论它是直接人类提示词、合成注入,还是已准入的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message`、`tool/result` 和 steering(中途引导)对应的 `steering/message` 也会存储完整的消息值。轮次执行仍由 `turn/start` 与 `turn/end` 包围,而空闲注入可以在轮次之间追加并刷新一条 `user/message`,无需运行模型。 +`user/message` 会直接存储完整的 `UserMessage`,其中包括路由或 pre-step 领取前创建的标识。无论它是直接人类提示词、合成注入,还是进入步骤的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message`、`tool/result` 和 steering(中途引导)对应的 `steering/message` 也会存储完整的消息值。轮次执行仍由 `turn/start` 与 `turn/end` 包围;`agent.inject()` 会把输入排队,直到某次 pre-step 返回 enter 并在轮次内记录它。 `tool/result` 持久保存一条带标识、user-role 的工具结果消息,以及可选内部失败标识和可选呈现元数据。工具成功时的规范 `value` 和便于人类阅读的规范失败消息只存在于执行本地;渲染后的错误内容是回放权威消息。 @@ -75,7 +75,7 @@ `SessionEventMap` 可通过合并扩展:插件使用声明合并添加自身类型(压缩 seam 的 `compact/*`、有界恢复的非 surface `llm/retry`、hook(钩子)桥接层的 `hook/*`);合并成员会出现在同一目录中。插件拥有其合并事件的关系不变量,包括是否允许纯日志事件出现在轮次之间。需要持久性的生产方通过 `Session` 追加,再等待 `ctx.sessions.flush(session)`,无需虚构一个执行轮次。 -此包还定义 `TurnEndReasonMap`,即用于轮次结束、可合并扩展且以 `kind` 为标签的和类型。`turn/start` 只携带轮次编号;之后已准入的 `user/message` 批次记录其输入,`llm/retry` 则记录请求恢复。 +此包还定义 `TurnEndReasonMap`,即用于轮次结束、可合并扩展且以 `kind` 为标签的和类型。`turn/start` 只携带轮次编号;之后进入步骤的 `user/message` 批次记录其输入,`llm/retry` 则记录请求恢复。 被中断的实时轮次以 `{ kind: 'aborted', reason: AgentCancelCause }` 结束,在持久 transcript(文本记录)中保留类型化取消原因。轮次失败携带 `{ kind: 'error', error }`;只有崩溃恢复会合成 `{ kind: 'interrupted' }`。 diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 32ad54dcd6..d814e612f1 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -167,9 +167,9 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change' */ export interface SessionEventMap { /** - * Opens turn `turn`. Every turn begins when the loop admits queued input; - * the following identified `user/message` event or batch records the - * admitted input. + * Opens turn `turn`. Every turn begins after the loop claims queued input + * and accepts the pre-step result; the following identified `user/message` + * event or batch records the messages entering the step. */ 'turn/start': { turn: number } /** @@ -187,9 +187,8 @@ export interface SessionEventMap { * A user-role message on the model-visible surface: a direct human prompt * (the queued message claimed for this turn), a synthetic `agent.inject()` * context (file-change notices, subdir AGENTS.md, skill content, cron - * notifications, …), or an admitted goal continuation round. All three - * project their `content` verbatim; `source` tells them apart. An idle - * injection may append this event between turns without running the model. + * notifications, …), or an entered goal continuation round. All three + * project their `content` verbatim; `source` tells them apart. */ 'user/message': UserMessage /** Raw stream chunk — token-level replay fidelity. */ diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index f22147bd4a..1a8938534f 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -46,7 +46,16 @@ async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promi async function composePrefix(ctx: Context): Promise { const agent = ctx.agentLoop.create(SessionId(`acp-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' }) - await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal) + const signal = new AbortController().signal + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', [], { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter', messages: [] }), + ) + if (decision.kind === 'enter') { + for (const message of decision.messages) { + agent.session.append('user/message', message, { surfaceOp: 'append' }) + } + } return agent.session.deriveMessages() } diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 49fc56d251..4c372f743c 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -39,7 +39,16 @@ declare module '@deepseek-ai/dsh-tasks' { async function composePrefix(ctx: Context, cwd: string): Promise { const agent = ctx.agentLoop.create(SessionId('agent-spine-prefix'), {}, { cwd }) - await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal) + const signal = new AbortController().signal + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', [], { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter', messages: [] }), + ) + if (decision.kind === 'enter') { + for (const message of decision.messages) { + agent.session.append('user/message', message, { surfaceOp: 'append' }) + } + } return agent.session.deriveMessages() } diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index 0a24f7dfa5..933466b2e3 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -43,7 +43,16 @@ async function mount(config: cliDemo.Config, withBash = false): Promise async function composePrefix(ctx: Context): Promise { const agent = ctx.agentLoop.create(SessionId(`cli-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' }) - await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal) + const signal = new AbortController().signal + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', [], { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter', messages: [] }), + ) + if (decision.kind === 'enter') { + for (const message of decision.messages) { + agent.session.append('user/message', message, { surfaceOp: 'append' }) + } + } return agent.session.deriveMessages() } diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index 5c162e0fdd..0db53e20b5 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -430,10 +430,10 @@ describe('runOneShot and executeCli', () => { && event.data.source.plugin === 'test')).toBe(false) }) - it('correlates a task whose admitted history is replaced', async () => { + it('correlates a task whose step history is replaced', async () => { const { ctx } = await harness([textResponse('rewritten answer')]) - ctx.on('agent/prompt-submit', async () => ({ - kind: 'allow', + ctx.on('agent/pre-step', async () => ({ + kind: 'enter', messages: [createUserMessage({ content: [{ type: 'text', text: 'rewritten task' }], source: { kind: 'plugin', plugin: 'test' }, @@ -446,26 +446,15 @@ describe('runOneShot and executeCli', () => { }) }) - it('settles blocked tasks at whole-agent idle without attributing a result', async () => { + it('settles rejected tasks at whole-agent idle without attributing a result', async () => { const blocked = await harness([]) - blocked.ctx.on('agent/prompt-submit', async () => ({ - kind: 'block' as const, - reason: 'denied', - discardClaimed: true, + blocked.ctx.on('agent/pre-step', async () => ({ + kind: 'reject' as const, })) await expect(runOneShot(blocked.ctx, { task: 'task' })).resolves.toMatchObject({ output: '' }) - const retained = await harness([]) - retained.ctx.on('agent/prompt-submit', async () => ({ - kind: 'block' as const, - reason: 'deferred', - discardClaimed: false, - })) - await expect(runOneShot(retained.ctx, { task: 'task' })).resolves.toMatchObject({ output: '' }) - expect(retained.agent.status).toBe('idle') - const failed = await harness([]) - failed.ctx.on('agent/prompt-submit', async () => { throw new Error('admission exploded') }) + failed.ctx.on('agent/pre-step', async () => { throw new Error('pre-step exploded') }) await expect(runOneShot(failed.ctx, { task: 'task' })).resolves.toMatchObject({ output: '' }) }) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index ef776d4de4..2f6379b320 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -33,7 +33,7 @@ function agent(ctx: Context, cwd: string): Agent { id, options: {}, session, - inbox: new Inbox(session), + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 78ba5abd6c..ba8c812bb7 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -16,7 +16,7 @@ interface Harness { readonly plugin: Awaited> } -/** Append one idle injection using the public Agent contract (idle inject wraps in a one-shot injection turn, per turn enclosure). */ +/** Commit one injected message as an already admitted turn for the Agent test double. */ function appendInjection(session: Session, input: UserMessage): void { const lastStart = session.events.findLast(event => event.type === 'turn/start') const turn = (lastStart?.data.turn ?? 0) + 1 @@ -34,7 +34,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } id: session.id, options: {}, session, - inbox: new Inbox(session), + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), ctx: new Context(), get status() { return status }, send: () => {}, diff --git a/packages/goal/goal-session/README.i18n.yaml b/packages/goal/goal-session/README.i18n.yaml index 71aa6413f7..b3fb104df7 100644 --- a/packages/goal/goal-session/README.i18n.yaml +++ b/packages/goal/goal-session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/goal/goal-session/README.md -README.md: 7c56f295bb7a587913b201db8860d11605886a64 -README.zh.md: e41afbc4142eee6a8a50e43b4fa6ca34ecc28641 +README.md: 89413062de8cbb49d7066ec3ae42769a99c939a2 +README.zh.md: b7298774399d1cd32bb27880179a79ecac06f074 diff --git a/packages/goal/goal-session/README.md b/packages/goal/goal-session/README.md index 7c56f295bb..89413062de 100644 --- a/packages/goal/goal-session/README.md +++ b/packages/goal/goal-session/README.md @@ -21,9 +21,9 @@ The plugin has no tunable configuration. `maxGoalRounds` belongs to the goal def ## Round contract -When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `` prompt with `GoalMessageSource`. Admission through `agent/prompt-submit` verifies the complete queued record and current goal both before and after downstream prompt hooks; only the accepted `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number. +When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `` prompt with `GoalMessageSource`. The `agent/pre-step` listener verifies the complete claimed record and current goal both before and after downstream listeners; only an entered `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number. -`MessageId` identifies the reserved message through durable inbox insertion and admission; it does not identify a turn result. Human messages do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until the agent becomes idle; a pending automatic prompt in a mixed batch is rejected and re-reserved only after that checkpoint. +`MessageId` identifies the reserved message through durable inbox insertion and claim; it does not identify a turn result. Human messages do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until the agent becomes idle; a pending automatic prompt in a mixed batch is rejected and re-reserved only after that checkpoint. The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, treats the current workspace, tool results, and durable session state as authoritative, requires evidence before completion, and tells the model to leave the goal active when work remains. Quoting preserves multiline or tag-like objective text as data. Goal lifecycle mutations still require the independent authority checks in `dsh-tool-goal`. diff --git a/packages/goal/goal-session/README.zh.md b/packages/goal/goal-session/README.zh.md index e41afbc414..b729877439 100644 --- a/packages/goal/goal-session/README.zh.md +++ b/packages/goal/goal-session/README.zh.md @@ -21,9 +21,9 @@ ## Round 契约 -当对应的活跃 agent 实例处于 idle 状态,且目标 phase 为 active、已启用续行并有剩余容量时,驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `` 提示词,并携带 `GoalMessageSource`。通过 `agent/prompt-submit` 准入时,会在下游提示词钩子前后验证完整的排队记录与当前 goal;只有被接受的 `user/message` 才会增加 `roundsStarted`。因陈旧而被拒绝的预留不会消耗 Round 编号。 +当对应的活跃 agent 实例处于 idle 状态,且目标 phase 为 active、已启用续行并有剩余容量时,驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `` 提示词,并携带 `GoalMessageSource`。`agent/pre-step` 监听器会在下游监听器前后验证完整的已领取记录与当前 goal;只有进入步骤的 `user/message` 才会增加 `roundsStarted`。因陈旧而被拒绝的预留不会消耗 Round 编号。 -`MessageId` 通过持久 inbox 插入和准入来标识预留消息;它不标识轮次结果。用户消息不消耗 goal 上限。如果用户工作在预留前进入 inbox,或加入预留的待处理批次,自动工作会让行,直到 agent 进入 idle;混合批次中的待处理自动提示词会被拒绝,只有完成该检查点后才重新预留。 +`MessageId` 通过持久 inbox 插入和领取来标识预留消息;它不标识轮次结果。用户消息不消耗 goal 上限。如果用户工作在预留前进入 inbox,或加入预留的待处理批次,自动工作会让行,直到 agent 进入 idle;混合批次中的待处理自动提示词会被拒绝,只有完成该检查点后才重新预留。 保留的提示词会点明经过 JSON 引用的目标与 `round/maxGoalRounds`,将当前工作区、工具结果和持久会话状态视为权威信息,要求在完成前提供证据,并要求在工作仍未完成时保持目标 active。引用可将多行或形似标签的目标文本保留为数据。goal 生命周期变更仍必须通过 `dsh-tool-goal` 的独立权限检查。 diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index 5a32195213..14a34693e8 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -6,7 +6,7 @@ import { isDeepStrictEqual } from 'node:util' import { FiberState } from 'cordis' import type { Context } from 'cordis' -import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' @@ -18,8 +18,6 @@ export { renderGoalRoundPrompt } from './prompt.ts' export const name = 'goal-session' export const inject = ['agents', 'goals', 'sessions'] -const STALE_ROUND_REASON = 'stale goal-round reservation' - /** Identity reserved before a goal continuation enters the agent inbox. */ interface RoundIdentity { readonly goalId: GoalRef['id'] @@ -125,12 +123,6 @@ export function apply(ctx: Context): void { } } - /** Remove only this driver's still-pending reservation. */ - function cancelReservation(agent: Agent, attempt: RoundAttempt): void { - const index = agent.inbox.nextTurn.findIndex(message => message.id === attempt.messageId) - if (index >= 0) agent.inbox.splice('next-turn', index, 1, [], 'canceled') - } - /** Process admitted work at quiescence, then reserve at most one next round. */ async function drive(state: DriverState): Promise { const { agent } = state @@ -204,7 +196,7 @@ export function apply(ctx: Context): void { /** Coalesce triggers onto one agent-local serialized driver. */ function requestDrive(state: DriverState): void { - /* v8 ignore next -- teardown may race a final trigger after synchronously closing admission */ + /* v8 ignore next -- teardown may race a final trigger after synchronously closing the step fence */ if (state.stopping) return state.requested = true if (state.run !== undefined) return @@ -238,7 +230,7 @@ export function apply(ctx: Context): void { }) } - // One composite effect keeps the admission fence installed until this + // One composite effect keeps the step fence installed until this // plugin's own scheduling tasks settle. ctx.effect(function* () { ctx.on('agent/error', (agent) => { @@ -329,7 +321,7 @@ export function apply(ctx: Context): void { && source.round === goal.roundsStarted + 1 } - ctx.on('agent/prompt-submit', async (agent, messages, signal, next): Promise => { + ctx.on('agent/pre-step', async (agent, messages, { signal }, next): Promise => { const submitted = messages.find(message => isGoalRoundSource(message.source)) if (submitted === undefined) return next() const { content, source } = submitted @@ -339,7 +331,7 @@ export function apply(ctx: Context): void { try { valid = validReservation(state, content, source) } catch (error: unknown) { - ctx.logger.warn(`goal-session: admission check failed for agent "${agent.id}": ${renderThrown(error)}`) + ctx.logger.warn(`goal-session: pre-step check failed for agent "${agent.id}": ${renderThrown(error)}`) disarm(state) } if (!valid) { @@ -347,30 +339,28 @@ export function apply(ctx: Context): void { if (attempt !== undefined && sameRound(source, attempt)) { attempt.stale = true state.attempt = undefined - cancelReservation(agent, attempt) } requestDrive(state) - return { kind: 'block', reason: STALE_ROUND_REASON, discardClaimed: false } + return { kind: 'reject' } } - let decision: PromptDecision + let decision: PreStepDecision try { decision = await next() } catch (error: unknown) { if (signal.aborted) throw error - // A throwing downstream hook drops the whole admission: the loop + // A throwing downstream hook drops the whole step proposal: the loop // returns to idle without a turn, so a still-queued reservation would // starve every later drive pass. Clear it and let the driver // reschedule the round. const attempt = state.attempt if (attempt !== undefined && sameRound(source, attempt) && attempt.phase === 'queued') { state.attempt = undefined - cancelReservation(agent, attempt) requestDrive(state) } throw error } if (signal.aborted) return decision - if (decision.kind === 'block') { + if (decision.kind === 'reject') { const attempt = state.attempt if (attempt !== undefined && sameRound(source, attempt)) state.attempt = undefined const goal = currentGoal(state) @@ -378,7 +368,7 @@ export function apply(ctx: Context): void { && goal.phase === 'active' && goal.activation === 'armed') { ctx.goals.block(agent, goalRef(goal), { code: 'prompt-rejected', - message: decision.reason, + message: 'Goal round was rejected before entering its step.', }) } return decision @@ -386,7 +376,7 @@ export function apply(ctx: Context): void { try { valid = validReservation(state, content, source) } catch (error: unknown) { - ctx.logger.warn(`goal-session: post-admission check failed for agent "${agent.id}": ${renderThrown(error)}`) + ctx.logger.warn(`goal-session: post-decision check failed for agent "${agent.id}": ${renderThrown(error)}`) disarm(state) valid = false } @@ -395,10 +385,9 @@ export function apply(ctx: Context): void { if (attempt !== undefined && sameRound(source, attempt)) { attempt.stale = true state.attempt = undefined - cancelReservation(agent, attempt) } requestDrive(state) - return { kind: 'block', reason: STALE_ROUND_REASON, discardClaimed: false } + return { kind: 'reject' } } return decision }) diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 6226372d13..eb4ebd3f8c 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { agentEvents } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -235,25 +235,28 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(1) }) - it('maps a downstream prompt veto to blocked without admitting the round', async () => { + it('maps a downstream step rejection to blocked without entering the round', async () => { const test = await harness([]) - test.ctx.on('agent/prompt-submit', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal' - ? Promise.resolve({ kind: 'block', reason: 'deployment policy', discardClaimed: true }) + test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal' + ? Promise.resolve({ kind: 'reject' as const }) : next()) test.ctx.goals.create(test.agent, { objective: 'respect policy' }) const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') expect(goal?.roundsStarted).toBe(0) - expect(goal?.blockedReason).toEqual({ code: 'prompt-rejected', message: 'deployment policy' }) + expect(goal?.blockedReason).toEqual({ + code: 'prompt-rejected', + message: 'Goal round was rejected before entering its step.', + }) expect(test.adapter.requests).toHaveLength(0) expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false) }) it('does not reserve again when a stopped-goal observer queues cancel-scoped work', async () => { const test = await harness([textResponse('human follow-up')]) - test.ctx.on('agent/prompt-submit', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal' - ? Promise.resolve({ kind: 'block', reason: 'stop this round', discardClaimed: true }) + test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal' + ? Promise.resolve({ kind: 'reject' as const }) : next()) test.ctx.on('goal/changed', (agent, change) => { if (change.operation === 'block') agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the blocker' }], source: { kind: 'user' } })) @@ -268,7 +271,7 @@ describe('same-session goal driving', () => { .toEqual([{ type: 'text', text: 'inspect the blocker' }]) }) - it('pauses and drops a reserved round when cancellation lands before admission', async () => { + it('pauses and drops a reserved round when cancellation lands before pre-step', async () => { const test = await harness([]) const cancel = onInboxMessage(test.ctx, test.agent, (message) => { if (message.source.kind === 'goal') { @@ -357,7 +360,7 @@ describe('same-session goal driving', () => { it('rechecks revision after downstream prompt hooks before admitting', async () => { const test = await harness([textResponse('new revision')]) let edited = false - test.ctx.on('agent/prompt-submit', (agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { if (messages[0]?.source.kind === 'goal' && !edited) { edited = true const current = test.ctx.goals.get(agent) @@ -366,7 +369,7 @@ describe('same-session goal driving', () => { } return next() }) - test.ctx.goals.create(test.agent, { objective: 'edit during admission', maxGoalRounds: 1 }) + test.ctx.goals.create(test.agent, { objective: 'edit during pre-step', maxGoalRounds: 1 }) const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') @@ -464,7 +467,7 @@ describe('same-session goal driving', () => { // attempt through cancel-requested) and THEN throws: the catch finds no // matching reservation and must not reschedule a paused goal. let fired = false - test.ctx.on('agent/prompt-submit', async (agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', async (agent, messages, _signal, next) => { if (messages[0]?.source.kind === 'goal' && !fired) { fired = true agent.cancel({ kind: 'user' }) @@ -483,15 +486,15 @@ describe('same-session goal driving', () => { expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'paused' }) }) - it('fails closed when a downstream admission hook throws', async () => { + it('fails closed when a downstream pre-step hook throws', async () => { const test = await harness([]) // Registered after goal-session's own listener: the throw propagates back - // through goal-session's next() await, dropping the whole admission. + // through goal-session's next() await, dropping the whole step proposal. let threw = false - test.ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { if (messages[0]?.source.kind === 'goal' && !threw) { threw = true - throw new Error('downstream admission hook exploded') + throw new Error('downstream pre-step hook exploded') } return next() }) @@ -610,20 +613,20 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(0) }) - it('fails a pre-admission read closed even when the first disarm attempt throws', async () => { + it('fails an initial pre-step read closed even when the first disarm attempt throws', async () => { const test = await harness([textResponse('retry after containment')]) let armed = true onInboxMessage(test.ctx, test.agent, (message) => { if (message.source.kind !== 'goal' || message.source.round <= 0 || !armed) return armed = false vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { - throw new Error('admission projection failed') + throw new Error('pre-step projection failed') }) vi.spyOn(test.ctx.goals, 'disarm').mockImplementationOnce(() => { throw 'disarm failed' }) }) - test.ctx.goals.create(test.agent, { objective: 'retry stale admission', maxGoalRounds: 1 }) + test.ctx.goals.create(test.agent, { objective: 'retry stale pre-step', maxGoalRounds: 1 }) await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') @@ -633,7 +636,7 @@ describe('same-session goal driving', () => { it('fails a post-hook read closed before the prompt can enter history', async () => { const test = await harness([]) let armed = true - test.ctx.on('agent/prompt-submit', (_agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => { if (messages[0]?.source.kind === 'goal' && armed) { armed = false vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { @@ -705,17 +708,17 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(0) }) - it('blocks admission when downstream cancellation clears the reservation', async () => { + it('rejects the step when downstream cancellation clears the reservation', async () => { const test = await harness([]) let cancelled = false - test.ctx.on('agent/prompt-submit', (agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { if (messages[0]?.source.kind === 'goal' && !cancelled) { cancelled = true agent.cancel({ kind: 'user' }) } return next() }) - test.ctx.goals.create(test.agent, { objective: 'cancel during admission' }) + test.ctx.goals.create(test.agent, { objective: 'cancel during pre-step' }) const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused') await test.agent.whenIdle() @@ -881,14 +884,12 @@ describe('same-session goal driving', () => { it('does not re-block a goal the downstream veto already saw cancelled', async () => { const test = await harness([]) let vetoed = false - test.ctx.on('agent/prompt-submit', (agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { if (messages[0]?.source.kind === 'goal' && !vetoed) { vetoed = true agent.cancel({ kind: 'user' }) - return Promise.resolve({ - kind: 'block', - reason: 'cancelled by policy', - discardClaimed: true, + return Promise.resolve({ + kind: 'reject', }) } return next() @@ -905,16 +906,16 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(0) }) - it('awaits an unadmitted reservation stuck in admission during teardown without cancelling', async () => { + it('awaits a claimed reservation stuck in pre-step during teardown without cancelling', async () => { const test = await harness([]) let release: (() => void) | undefined - test.ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { if (messages[0]?.source.kind === 'goal' && release === undefined) { await new Promise((resolve) => { release = resolve }) } return next() }) - test.ctx.goals.create(test.agent, { objective: 'unload during admission' }) + test.ctx.goals.create(test.agent, { objective: 'unload during pre-step' }) await vi.waitFor(() => { expect(release).toBeDefined() }) const disposal = Promise.resolve(test.driver.dispose()) diff --git a/packages/goal/goal/README.i18n.yaml b/packages/goal/goal/README.i18n.yaml index 1c724ef9c0..142a6736bf 100644 --- a/packages/goal/goal/README.i18n.yaml +++ b/packages/goal/goal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/goal/goal/README.md -README.md: 45d7ac2e1c8fbd60ab2bc8b917b326d20da02ec1 -README.zh.md: b657d1d52f75657ba44b99306e4621aa82688e96 +README.md: cba28b2de6cb59932f25ad57a32bc25e0d5e287a +README.zh.md: aebb3a1e773038888a109dc040d3389988231953 diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index 45d7ac2e1c..cba28b2de6 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -21,7 +21,7 @@ Event-sourced same-session goal state. The service retains one current completio At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation. -Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The model-visible `user/message` content and its typed `{ kind: 'goal', change }` source must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward. +Every mutation queues a complete versioned snapshot through `agent.inject()`; clear uses a revisioned tombstone. A later entering pre-step records it as a model-visible `user/message`, whose content and typed `{ kind: 'goal', change }` source must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward. Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained. diff --git a/packages/goal/goal/README.zh.md b/packages/goal/goal/README.zh.md index b657d1d52f..aebb3a1e77 100644 --- a/packages/goal/goal/README.zh.md +++ b/packages/goal/goal/README.zh.md @@ -21,7 +21,7 @@ 最多只有一个当前目标。创建操作会生成 revision 为 1、phase 为 active 的目标并启用续行。未完成的目标必须编辑、转换或清除;已完成目标可以由拥有全局未使用过的 id 的目标替换。编辑会保留 phase、blocker reason 与 activation。暂停、完成、阻塞和清除都会停用续行。阻塞会记录策略自有的 lower-kebab-case 代码和规范化的自由文本说明;提供方限制、配置预算、执行错误与请求人工输入都使用这一种持久 phase,不会扩增生命周期状态。只有配置的 Round 上限仍有剩余容量时,resume 才接受已停止 phase 或 phase 为 active 但已停用续行的目标;它会清除原 blocker reason。phase 为 active 且已启用续行的目标会拒绝冗余操作。 -每次非 clear 变更都会通过 `agent.inject()` 追加完整的版本化快照;clear 则追加带 revision 的 tombstone。模型可见的 `user/message` 内容与其带类型的 `{ kind: 'goal', change }` 来源必须完全一致。回放会拒绝形状错误、来源/内容漂移、不连续 revision、非法生命周期转换、每目标时间戳非单调,以及不连续的 Goal Round。挂钟时间倒退时,变更时间戳会限制在不早于上一次目标更新的值。 +每次变更都会通过 `agent.inject()` 把完整的版本化快照排队;clear 使用带 revision 的 tombstone。后续返回 enter 的 pre-step 会把它记录为模型可见的 `user/message`,其内容与带类型的 `{ kind: 'goal', change }` 来源必须完全一致。回放会拒绝形状错误、来源/内容漂移、不连续 revision、非法生命周期转换、每目标时间戳非单调,以及不连续的 Goal Round。挂钟时间倒退时,变更时间戳会限制在不早于上一次目标更新的值。 注入可以立即追加,也可能在活跃工具批次 FIFO 中等待。服务会在内存中叠加已接受的待处理变更,并在每个完全一致的载荷进入日志时逐一完成对账,因此连续的模型工具变更可以看到自身最新 revision,而不会把尚未记录的缓存当作持久状态。可重入追加观察者会且只会看到每项已接受变更一次;增量回放会把游标保留在第一个损坏事件处。追加或入队成功后才触发 `goal/changed`;监听器失败会被隔离处理。 diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 7ee364c130..59dee09477 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -560,7 +560,7 @@ export class GoalService extends Service { return view } - /** Accept one mutation into the agent log/FIFO, cache, and live event stream. */ + /** Accept one mutation into the agent injection queue, cache, and live event stream. */ private commit(agent: Agent, cache: GoalCache, change: GoalChangeMeta, activation: GoalActivation): void { const ref = goalChangeRef(change) const pending: PendingGoalChange = { change, activation, applied: false } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 47c9b6b1ad..edded2bf86 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -24,7 +24,7 @@ interface StubAgent { drain(): void } -/** Number the next balanced one-shot injection turn. */ +/** Number the next balanced test-fixture turn. */ function nextTurn(session: Session): number { return session.events.reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1 } @@ -44,7 +44,7 @@ function stubAgentForSession(session: Session): StubAgent { id, options: {}, session, - inbox: new Inbox(session), + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), ctx: new Context(), get status() { return status }, send: () => {}, diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index ccff0b7491..fd20a9db06 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -35,7 +35,7 @@ function liveAgent(ctx: Context, session: Session): Agent { id: session.id, options: {}, session, - inbox: new Inbox(session), + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), ctx, get status() { return status }, send: () => {}, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index b99463a586..5aec15f675 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -29,7 +29,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { id: session.id, options: {}, session, - inbox: new Inbox(session), + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), get status() { return status }, ctx: new Context(), send: () => {}, diff --git a/packages/guard/README.i18n.yaml b/packages/guard/README.i18n.yaml index c323a9b295..819da8c487 100644 --- a/packages/guard/README.i18n.yaml +++ b/packages/guard/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/guard/README.md -README.md: b7375fd2bb12ae0cec94b13e6a1012c6f143bdad -README.zh.md: bba5c144d0663266e4327e388b9915cde176ce08 +README.md: 9b41efa619b049c8ce35b3b48a5284c3c63d14c7 +README.zh.md: 67371448d6788971acf94ad5577ac88c0ae7a734 diff --git a/packages/guard/README.md b/packages/guard/README.md index b7375fd2bb..9b41efa619 100644 --- a/packages/guard/README.md +++ b/packages/guard/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Behavioral guard plugins that watch the agent loop for unproductive patterns and nudge the model back on course. A single **product** package — there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability. +Behavioral guard plugins that watch the agent loop for unproductive patterns and nudge the model back on course. A single **product** package — there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/post-execute`, `agent/pre-step`, `agent/status`), not a swappable capability. | Package | Role | ctx key | |---|---|---| diff --git a/packages/guard/README.zh.md b/packages/guard/README.zh.md index bba5c144d0..67371448d6 100644 --- a/packages/guard/README.zh.md +++ b/packages/guard/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这组行为 guard 插件会监视 agent loop(智能体循环)中的低效模式,并提醒模型调整方向。这里只有一个**产品**包(package),不设接口/实现 seam:guard 是现有核心 seam(`tools/post-execute`、`agent/prompt-submit`、`agent/status`)的自包含消费方,并非可替换能力。 +这组行为 guard 插件会监视 agent loop(智能体循环)中的低效模式,并提醒模型调整方向。这里只有一个**产品**包(package),不设接口/实现 seam:guard 是现有核心 seam(`tools/post-execute`、`agent/pre-step`、`agent/status`)的自包含消费方,并非可替换能力。 | 包 | 职责 | ctx 键 | |---|---|---| diff --git a/packages/guard/repeat-tool-guard/README.i18n.yaml b/packages/guard/repeat-tool-guard/README.i18n.yaml index 4863dcd147..3de92a31fa 100644 --- a/packages/guard/repeat-tool-guard/README.i18n.yaml +++ b/packages/guard/repeat-tool-guard/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/guard/repeat-tool-guard/README.md -README.md: 226dba10239031e8e79bd5698e77c213688ce579 -README.zh.md: fd6e291d6fe9f286d3dc3f921518f596f3749e3e +README.md: 0f67c0e062ebf3963727afa2a8539e3f08a1b6a6 +README.zh.md: fc11161347e7470f8cd7e317c9e27d0281b1e0b9 diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index 226dba1023..0f67c0e062 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -27,7 +27,7 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de - **Untracked calls are transparent to the chain.** A call excluded by `include`/`exclude` neither increments nor resets the counter, so `grep X → todo_write → grep X` still counts as two consecutive `grep X` when `todo_write` is excluded. This is what makes exclusion useful: bookkeeping tools interleaved into a loop must not launder it. - **Denied calls count.** Detection sits on `tools/post-execute`, which also runs for calls a `tools/pre-execute` listener denied — a model hammering a denied call is exactly the loop worth breaking. - **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller has no model to remind and no live agent object to key on. -- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so a `WeakMap` keys each chain by the live agent object; one agent's repetition never trips another's reminder. A user prompt (`agent/prompt-submit`) resets the submitting agent's chain, and object lifetime bounds the weak entry without a disposal listener. +- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so a `WeakMap` keys each chain by the live agent object; one agent's repetition never trips another's reminder. A user prompt (`agent/pre-step`) resets the submitting agent's chain, and object lifetime bounds the weak entry without a disposal listener. - **In-memory only.** A session resumed from persistence starts with a fresh chain — the guard is a heuristic nudge, not a logged invariant, later reminders are the accepted cost. ## Reminder delivery diff --git a/packages/guard/repeat-tool-guard/README.zh.md b/packages/guard/repeat-tool-guard/README.zh.md index fd6e291d6f..fc11161347 100644 --- a/packages/guard/repeat-tool-guard/README.zh.md +++ b/packages/guard/repeat-tool-guard/README.zh.md @@ -27,7 +27,7 @@ - **不受跟踪的调用对链透明。** 被 `include`/`exclude` 排除的调用既不递增计数器,也不重置计数器;因此,`grep X → todo_write → grep X` 仍算作连续两次 `grep X`,即使 `todo_write` 已被排除。这正是排除机制的价值:循环中穿插的记录类工具不能掩盖循环。 - **被拒绝的调用也计数。** 检测位于 `tools/post-execute`;即便调用被 `tools/pre-execute` 监听器拒绝,该事件也会运行。模型反复尝试被拒绝的调用,恰恰是需要打断的循环。 - **忽略没有 agent 的调用。** 直接调用 `ctx.tools.execute()` 的调用方没有需要提醒的模型,也没有可作为键的活跃 agent 对象。 -- **按 agent 分键。** 工具注册表位于上下文层级,subagent 会交错通过同一个 waterfall(瀑布式事件),因此每条链使用 `WeakMap`,以活跃 agent 对象为键。一个 agent 的重复调用绝不会触发另一个 agent 的提醒。用户提示词(`agent/prompt-submit`)会重置提交该提示词的 agent 链;对象生命周期会自然限制弱引用条目的寿命,无需 dispose(资源释放)监听器。 +- **按 agent 分键。** 工具注册表位于上下文层级,subagent 会交错通过同一个 waterfall(瀑布式事件),因此每条链使用 `WeakMap`,以活跃 agent 对象为键。一个 agent 的重复调用绝不会触发另一个 agent 的提醒。用户提示词(`agent/pre-step`)会重置提交该提示词的 agent 链;对象生命周期会自然限制弱引用条目的寿命,无需 dispose(资源释放)监听器。 - **仅驻留内存。** 从持久化恢复的会话会从一条全新的链开始:guard 是启发式提醒,并非有日志记录的不变量;提醒会延后,这是可接受的代价。 ## 提醒传递 diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index 09ebbe8bf5..d58d4f0528 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import z from 'schemastery' -import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' @@ -223,8 +223,8 @@ export function apply(ctx: Context, config: Config): void { // A user interjection changes the context; repetition across it is not a // loop. Pure reset hook: always delegates (attaching nothing, vetoing // nothing). - ctx.on('agent/prompt-submit', (agent, _message, _signal, next): Promise => { - chains.delete(agent) + ctx.on('agent/pre-step', (agent, messages, _context, next): Promise => { + if (messages.some(message => message.source.kind === 'user')) chains.delete(agent) return next() }) } diff --git a/packages/hooks/hook-protocol/src/types.ts b/packages/hooks/hook-protocol/src/types.ts index e14473b3e1..6a3dd616f3 100644 --- a/packages/hooks/hook-protocol/src/types.ts +++ b/packages/hooks/hook-protocol/src/types.ts @@ -81,7 +81,7 @@ export type MatcherMode = 'claude' | 'codex' /** * The dialect-neutral OUTCOME a hook produced, parsed from its exit code + * stdout JSON + stderr by {@link parseHookOutput}. A bridge maps this onto a - * seam-specific typed Decision (PreToolDecision, PromptDecision, …). Every field + * seam-specific typed Decision (PreToolDecision, PreStepDecision, …). Every field * is OPTIONAL because a hook may exercise any subset; the bridge decides which * fields are meaningful for its hook point and which it ignores (faithful-but- * degraded — e.g. Codex ignores `allow`/`ask`). diff --git a/packages/hooks/hooks-claude/README.i18n.yaml b/packages/hooks/hooks-claude/README.i18n.yaml index ed15dbf7a6..f59bb6a817 100644 --- a/packages/hooks/hooks-claude/README.i18n.yaml +++ b/packages/hooks/hooks-claude/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/hooks/hooks-claude/README.md -README.md: 61c2d152dacdbec31bca015b94b9f2ac6d24c3aa -README.zh.md: 38509ab6e6f72bb62a6bed064257603f728812cb +README.md: c97643832821746b816d80d498e8a66fbb9db895 +README.zh.md: 0d6cdd60321b1c254c0b36ffc0040fdb1a3f5cb4 diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index 61c2d152da..c976438328 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -37,7 +37,7 @@ The hooks **themselves** run in the agent's session workspace: for the agent-sco | CC hook | Harness seam | Mapping | |---|---|---| | `SessionStart` | `agent/session-start` (emit) | additionalContext → `agent.inject()` into the new session (cannot block) | -| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `deny` → `PromptDecision.block`; additionalContext-only → delegate via `next()` then prepend a separately sourced context to downstream `additionalContexts` (a later listener can still block/rewrite) | +| `UserPromptSubmit` | `agent/pre-step` (waterfall) | `deny` → `PreStepDecision.reject`; additionalContext-only → delegate via `next()` then append a separately sourced message to a downstream `enter` decision (a later outer listener can still reject/rewrite) | | `PreToolUse` | `tools/pre-execute` (waterfall) | `deny` → `PreToolDecision.deny`; `ask` → `PreToolDecision.ask` | | `PostToolUse` | `tools/post-execute` (waterfall) | `deny` → `block` with feedback; additionalContext-only → delegate via `next()` then prepend a separately sourced context to the downstream decision; Code Mode defers sub-call contexts until the outer `run_code` result | | `Stop` | `agent/turn-stopping` (serial) | a blocking Stop hook feeds its reason through `steer()`, forcing another step | @@ -52,7 +52,7 @@ Every agent-scoped stdin payload carries `session_id` and string-shaped `transcr ## Context source -Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }` source. `agent.inject()` defaults a missing source to `{ kind: 'user' }`, which would mislabel plugin context as a user prompt — so the bridge always names itself. +Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }` source so the durable message is never mistaken for a user prompt. ## Model Experience diff --git a/packages/hooks/hooks-claude/README.zh.md b/packages/hooks/hooks-claude/README.zh.md index 38509ab6e6..0d6cdd6032 100644 --- a/packages/hooks/hooks-claude/README.zh.md +++ b/packages/hooks/hooks-claude/README.zh.md @@ -37,7 +37,7 @@ hook **本身**会在 agent 的会话工作区中运行:对 agent scope 点, | CC hook | Harness seam | 映射 | |---|---|---| | `SessionStart` | `agent/session-start`(emit) | additionalContext → `agent.inject()` 到新会话(无法阻塞) | -| `UserPromptSubmit` | `agent/prompt-submit`(waterfall,瀑布式事件) | `deny` → `PromptDecision.block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游 `additionalContexts`(后续 listener 仍可阻塞/改写) | +| `UserPromptSubmit` | `agent/pre-step`(waterfall,瀑布式事件) | `deny` → `PreStepDecision.reject`;仅 additionalContext → 通过 `next()` 委托,再向下游 `enter` 决策追加一条单独标记来源的消息(后续外层 listener 仍可 reject/改写) | | `PreToolUse` | `tools/pre-execute`(waterfall) | `deny` → `PreToolDecision.deny`;`ask` → `PreToolDecision.ask` | | `PostToolUse` | `tools/post-execute`(waterfall) | `deny` → 带反馈的 `block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游决策;Code Mode 将子调用上下文延迟到外层 `run_code` 结果 | | `Stop` | `agent/turn-stopping`(serial) | 阻塞 Stop hook 通过 `steer()` 送入其原因,强制再执行一步 | @@ -52,7 +52,7 @@ matcher subject 是工具名称(`PreToolUse`/`PostToolUse`)、会话源( ## 上下文源 -注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-claude' }` 源。`agent.inject()` 会将缺失源默认为 `{ kind: 'user' }`,这会将插件上下文错误标记为用户提示词,因此桥接始终标注自身。 +注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-claude' }` 来源,因此持久消息绝不会被误认为用户提示词。 ## 模型体验 diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index e16c27dac5..2398f336fc 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -12,7 +12,7 @@ import { readFileSync } from 'node:fs' import type { Context } from 'cordis' import z from 'schemastery' -import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' @@ -197,11 +197,6 @@ export function apply(ctx: Context, config: Config): void { return [ours, ...theirs ?? []] } - /** Append hook context to an admitted inbox batch. */ - function appendPromptContext(theirs: UserMessage[], ours: UserMessage): UserMessage[] { - return [...theirs, ours] - } - // SessionStart injects context when its detached hook resolves; a slow hook // may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. @@ -216,26 +211,23 @@ export function apply(ctx: Context, config: Config): void { })) }) - // --- UserPromptSubmit → PromptDecision. The prompt text is the payload; no + // --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no // matcher subject (CC ignores matchers for this event). --- - ctx.on('agent/prompt-submit', async (agent, messages, signal, next): Promise => { + ctx.on('agent/pre-step', async (agent, messages, { signal }, next): Promise => { + if (messages.length === 0) return next() const content = messages.flatMap(message => message.content) const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, signal }) if (merged.decision === 'deny') { - return { - kind: 'block', - reason: merged.reason ?? 'blocked by UserPromptSubmit hook', - discardClaimed: true, - } + return { kind: 'reject' } } - // Delegate so later listeners may still rewrite or block, then prepend our - // context only to a downstream allow decision. + // Delegate so later listeners may still rewrite or reject, then prepend our + // context only to a downstream enter decision. const downstream = await next() const ours = contextFrom(merged) - if (!ours || downstream.kind !== 'allow') return downstream + if (!ours || downstream.kind !== 'enter') return downstream return { - kind: 'allow', - messages: appendPromptContext(downstream.messages, ours), + kind: 'enter', + messages: [...downstream.messages, ours], } }) diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index c23625b392..ecf1386f9e 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -89,7 +89,7 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): } describe('hooks-claude bridge — UserPromptSubmit', () => { - it('a UserPromptSubmit hook that exits 2 rejects admission without a turn', async () => { + it('a UserPromptSubmit hook that exits 2 rejects step entry without a turn', async () => { // UserPromptSubmit ignores its malformed matcher field, then exit 2 blocks // with the reason on stderr. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) @@ -108,7 +108,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { // The prompt was blocked before the model and before a turn opened. expect(adapter.requests).toHaveLength(0) expect(events(agent).some(e => e.type === 'turn/start')).toBe(false) - // Admission has no open turn in which turn-scoped hook provenance could live. + // Pre-step has no open turn in which turn-scoped hook provenance could live. expect(events(agent).some(e => e.type === 'hook/invoked' || e.type === 'hook/result')).toBe(false) }) diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index b0a0a2a363..3fa945a63c 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -495,10 +495,8 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(path, adapter) // A later listener that blocks every prompt (registered AFTER the bridge). - ctx.on('agent/prompt-submit', async () => ({ - kind: 'block' as const, - reason: 'policy veto', - discardClaimed: true, + ctx.on('agent/pre-step', async () => ({ + kind: 'reject' as const, })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) @@ -511,15 +509,15 @@ export function defineCoverageCases(group: CoverageGroup): void { }) it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => { - // Both the bridge hook and a later prompt-submit listener attach context; the + // Both the bridge hook and a later pre-step listener attach context; the // request must see both as separately sourced durable events. const d = dir() const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) - ctx.on('agent/prompt-submit', async (_agent, messages) => ({ - kind: 'allow' as const, + ctx.on('agent/pre-step', async (_agent, messages) => ({ + kind: 'enter' as const, messages: [{ ...messages[0]!, content: [{ type: 'text' as const, text: 'rewritten-prompt' }], @@ -540,8 +538,8 @@ export function defineCoverageCases(group: CoverageGroup): void { expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true) const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ - { kind: 'plugin', plugin: 'hooks-claude' }, { kind: 'plugin', plugin: 'policy' }, + { kind: 'plugin', plugin: 'hooks-claude' }, ]) }) diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index 90e7f7c1dd..e7a4aac891 100644 --- a/packages/hooks/hooks-codex/README.i18n.yaml +++ b/packages/hooks/hooks-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/hooks/hooks-codex/README.md -README.md: e906810ed58c3d0204c618c32787af06c91cfb78 -README.zh.md: 4940fdb976dd963bbb2e41c0ec6ef274ee475334 +README.md: 60dd1d098966aad6ccdb0957ee223b9843db499f +README.zh.md: c84337e9e5b559d5bf7671cc1d1eef429d8fbce5 diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index e906810ed5..60dd1d0989 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -43,7 +43,7 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped | Codex hook | Harness seam | Mapping | |---|---|---| | `SessionStart` | `agent/session-start` (emit) | a plain-stdout hook's output → additionalContext → `agent.inject()` | -| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `block` (exit 2) → `PromptDecision.block`; additionalContext-only → delegate via `next()` then prepend a separately sourced context to downstream `additionalContexts` | +| `UserPromptSubmit` | `agent/pre-step` (waterfall) | `block` (exit 2) → `PreStepDecision.reject`; additionalContext-only → delegate via `next()` then append a separately sourced message to a downstream `enter` decision | | `PreToolUse` | `tools/pre-execute` (waterfall) | `block` → `PreToolDecision.deny` (no `allow`/`ask`) | | `PostToolUse` | `tools/post-execute` (waterfall) | `block` → `block` with feedback; additionalContext-only → delegate via `next()` then prepend a separately sourced context to the downstream decision; Code Mode defers sub-call contexts until the outer `run_code` result | | `Stop` | `agent/turn-stopping` (serial) | a blocking Stop hook feeds its reason through `steer()`, forcing another step | @@ -56,7 +56,7 @@ Every agent-scoped stdin payload carries `session_id` and `transcript_path`. The ## Context source -Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }` source (`agent.inject()` would otherwise default it to `{ kind: 'user' }`). +Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }` source so the durable message is never mistaken for a user prompt. ## Model Experience diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md index 4940fdb976..c84337e9e5 100644 --- a/packages/hooks/hooks-codex/README.zh.md +++ b/packages/hooks/hooks-codex/README.zh.md @@ -43,7 +43,7 @@ hook 本身会在 agent(智能体)的会话工作区中运行:对 agent sc | Codex hook | Harness seam | 映射 | |---|---|---| | `SessionStart` | `agent/session-start`(emit) | 纯 stdout hook 的输出 → additionalContext → `agent.inject()` | -| `UserPromptSubmit` | `agent/prompt-submit`(waterfall,瀑布式事件) | `block`(退出码 2)→ `PromptDecision.block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游 `additionalContexts` | +| `UserPromptSubmit` | `agent/pre-step`(waterfall,瀑布式事件) | `block`(退出码 2)→ `PreStepDecision.reject`;仅 additionalContext → 通过 `next()` 委托,再向下游 `enter` 决策追加一条单独标记来源的消息 | | `PreToolUse` | `tools/pre-execute`(waterfall) | `block` → `PreToolDecision.deny`(没有 `allow`/`ask`) | | `PostToolUse` | `tools/post-execute`(waterfall) | `block` → 带反馈的 `block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游决策;Code Mode 将子调用上下文延迟到外层 `run_code` 结果 | | `Stop` | `agent/turn-stopping`(serial) | 阻塞 Stop hook 通过 `steer()` 送入其原因,强制再执行一步 | @@ -56,7 +56,7 @@ hook 本身会在 agent(智能体)的会话工作区中运行:对 agent sc ## 上下文源 -注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-codex' }` 源(否则 `agent.inject()` 会将其默认为 `{ kind: 'user' }`)。 +注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-codex' }` 来源,因此持久消息绝不会被误认为用户提示词。 ## 模型体验 diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 1aee38fff2..e0587ba449 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -15,7 +15,7 @@ import { readFileSync } from 'node:fs' import type { Context } from 'cordis' import z from 'schemastery' -import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' @@ -182,11 +182,6 @@ export function apply(ctx: Context, config: Config): void { return [ours, ...theirs ?? []] } - /** Append hook context to an admitted inbox batch. */ - function appendPromptContext(theirs: UserMessage[], ours: UserMessage): UserMessage[] { - return [...theirs, ours] - } - // SessionStart injects plain stdout when its detached hook resolves; a slow // hook may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. @@ -200,30 +195,27 @@ export function apply(ctx: Context, config: Config): void { /* jscpd:ignore-end */ }) - // UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask. - ctx.on('agent/prompt-submit', async (agent, messages, signal, next): Promise => { + // UserPromptSubmit → PreStepDecision. Codex supports reject, not rewrite or ask. + ctx.on('agent/pre-step', async (agent, messages, { turn, signal }, next): Promise => { + if (messages.length === 0) return next() const payload = { ...base(ctx, agent, 'UserPromptSubmit', model), - turn_id: String(lastTurn(agent) + 1), + turn_id: String(turn), prompt: blocksToText(messages.flatMap(message => message.content)), } const merged = await runPoint('UserPromptSubmit', '', payload, { agent, plainStdoutAsContext: true, signal }) /* jscpd:ignore-start */ if (merged.decision === 'deny') { - return { - kind: 'block', - reason: merged.reason ?? 'blocked by UserPromptSubmit hook', - discardClaimed: true, - } + return { kind: 'reject' } } - // Context alone is not a veto: DELEGATE so a later prompt-submit listener can - // still block/rewrite, then fold our context onto its decision. + // Context alone is not a veto: DELEGATE so a later pre-step listener can + // still reject/rewrite, then fold our context onto its decision. const downstream = await next() const ours = contextFrom(merged) - if (!ours || downstream.kind !== 'allow') return downstream + if (!ours || downstream.kind !== 'enter') return downstream return { - kind: 'allow', - messages: appendPromptContext(downstream.messages, ours), + kind: 'enter', + messages: [...downstream.messages, ours], } }) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 419792ca08..0ab178b6a9 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -81,7 +81,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro expect((await capture()).payload.transcript_path).toBeNull() }, 15_000) // Two real agent/hook subprocess loops need process startup and teardown headroom. - it('UserPromptSubmit block (exit 2) rejects admission without a turn', async () => { + it('UserPromptSubmit block (exit 2) rejects step entry without a turn', async () => { const d = dir() hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) const adapter = new MockAdapter([textResponse('no')]) @@ -109,10 +109,8 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.on('agent/prompt-submit', async () => ({ - kind: 'block' as const, - reason: 'policy veto', - discardClaimed: true, + ctx.on('agent/pre-step', async () => ({ + kind: 'reject' as const, })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) @@ -126,8 +124,8 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.on('agent/prompt-submit', async (_agent, messages) => ({ - kind: 'allow' as const, + ctx.on('agent/pre-step', async (_agent, messages) => ({ + kind: 'enter' as const, messages: [{ ...messages[0]!, content: [{ type: 'text' as const, text: 'rewritten-prompt' }], @@ -144,8 +142,8 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro expect(req).toContain('rewritten-prompt') const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ - { kind: 'plugin', plugin: 'hooks-codex' }, { kind: 'plugin', plugin: 'policy' }, + { kind: 'plugin', plugin: 'hooks-codex' }, ]) }) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index b253b47837..27dfdbf5de 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: cf8cf0aaa7e20436644b92a996b6f7a817b1dd31 -README.zh.md: 91b3d1d0577d4a9a7df97d3c785e28bb21e47fd1 +README.md: 185c20c15c53529c14ad00b38413ffba1d63cfd4 +README.zh.md: ef62ab53e32d564d5041befdb8500189a74a0e05 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index cf8cf0aaa7..185c20c15c 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -20,7 +20,7 @@ Session titles ride the generic projection pair like every other domain — the Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. -Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. +Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. The operation never resumes a cold session, and the client never infers retirement from turn or status events. Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 91b3d1d057..ef62ab53e3 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -20,7 +20,7 @@ 会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 -待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 +待处理的 queued 输入属于实时控制平面契约,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。该操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 6008b16922..63ad5c0f7b 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -63,7 +63,7 @@ async function harness(options: { commands?: boolean; skills?: boolean } = {}): /** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */ function stubAgent(ctx: Context, sessionId?: SessionId): Agent { const session = ctx.sessions.create(sessionId) - const inbox = new Inbox(session) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} }) const agent = { id: session.id, session, diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index f6acfe81d4..cb7729853c 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -54,7 +54,7 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: if (withRegistry) await ctx.plugin(SessionProjectionRegistry) const session = ctx.sessions.create() // The gateway reads both the session and durable inbox baseline. - ctx.agents.register({ id: session.id, session, inbox: new Inbox(session), status: 'idle', ctx } as Agent) + ctx.agents.register({ id: session.id, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx } as Agent) return { ctx, session } } diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 1b324a9794..6610e71d8b 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -44,7 +44,7 @@ function stubAgent(session: Session): Agent { id: session.id, options: {}, session, - inbox: new Inbox(session), + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx: new Context(), send: () => {}, diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index a4b6723b98..1c932256a0 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md -README.md: 6f0a9ac477b49b96ddfc2ce667e3556dec727569 -README.zh.md: 922b153aa1b08e1a6003f63736ea402787bff1dd +README.md: c23dd0c42d8b1d1975b30b08e5be8758a325a9c7 +README.zh.md: 88640381d736244ad56aa802cee6ed70c6c75c20 diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 6f0a9ac477..c23dd0c42d 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -8,7 +8,7 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc `plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`. -`ctx.planMode.set(agent, active)` commits immediately when the agent is idle — no boundary would arrive until the next prompt, so the standalone `plan/mode` event lands at once — and holds a pending selection for the next in-turn request boundary while the agent is running; it returns which of the two happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's mid-turn selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths). +`ctx.planMode.set(agent, active)` commits immediately when the agent is idle — no boundary would arrive until the next prompt, so the standalone `plan/mode` event lands at once — and holds a pending selection for the next in-turn request boundary while the agent is running; it returns which of the two happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's mid-turn selection. Initial and continuation pre-step boundaries plus request-recovery retries are covered; a changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths). ## Model and human surfaces diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 922b153aa1..88640381d7 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -8,7 +8,7 @@ `plan/mode`(`{ active: boolean }`)是一个仅写日志、整值替换的 `SessionEventMap` 成员。`foldPlanMode(events)` 返回最后记录的值,如果没有则返回 `false`,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复 plan 状态。UI 通过 `session/event` 观察已提交的切换。 -`ctx.planMode.set(agent, active)` 在 agent 空闲时立即提交——下一个 prompt 之前不会有任何边界到来,因此独立的 `plan/mode` 事件当场落账——在 agent 运行中则持有待生效选择、等下一个轮内请求边界;返回值说明发生了哪种(`committed`/`queued`)、一次 `cancelled` 反转或 `noop`。`get(agent)` 返回 `{ active, pending? }`,将塑造当前步骤的日志状态与用户的轮中选择分开。提示词提交、常规续行和请求恢复重试都在覆盖范围内;当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。 +`ctx.planMode.set(agent, active)` 在 agent 空闲时立即提交——下一个 prompt 之前不会有任何边界到来,因此独立的 `plan/mode` 事件当场落账——在 agent 运行中则持有待生效选择、等下一个轮内请求边界;返回值说明发生了哪种(`committed`/`queued`)、一次 `cancelled` 反转或 `noop`。`get(agent)` 返回 `{ active, pending? }`,将塑造当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 边界以及请求恢复重试都在覆盖范围内;当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。 ## 模型与人类界面 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index b2a5f6076d..eb6023705c 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -8,8 +8,8 @@ * * The state in force is folded from the session log (`plan/mode`, last one * wins), so resume and fork restore it without a live mirror. User selections - * are held as pending intent until an in-turn request boundary because every - * session event is turn-enclosed. The service flushes at `agent/step` before + * are held as pending intent until an in-turn step boundary because every + * session event is turn-enclosed. The service flushes on `step/start` before * the affected request assembly, including retry turns. * * The exit tool remains registered while plan mode is inactive so crossing a @@ -24,9 +24,9 @@ import { Context, Service } from 'cordis' import { z as zod } from 'zod' import type { ZodType } from 'zod' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' @@ -197,21 +197,32 @@ export class PlanModeService extends Service { this.section = resolveConfig(config).section let disposed = false - // The boundary flush uses the loop's `agent/step` interception seam, not - // post-commit `session/event` observation. `agent/step` runs inside the - // open turn before every request derivation (including turn 1 step 1), so - // it is the sole flush point: prompt admission happens pre-turn, where a - // `plan/mode` append would land outside any open turn. Failures are - // contained so policy cannot block a turn; a failed append remains - // pending for a later boundary. - ctx.on('agent/step', (agent) => { - if (disposed) return + // Pre-step runs before the turn opens, so the turn-enclosed mode event + // commits from the immediately following step/start observer. Request + // assembly happens afterward. A failed append remains pending for a later + // boundary, and policy cannot block the turn. + ctx.on('session/event', (session, event) => { + if (disposed || event.type !== 'step/start') return try { - this.onBoundary(agent) + this.onBoundary(session) } catch (error) { ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error) } }, { prepend: true }) + ctx.on('agent/pre-step', async ( + agent, + _messages, + _signal, + next, + ): Promise => { + const decision = await next() + const pending = this.pendingIntents.get(agent.session) + if (decision.kind === 'reject' || pending?.narrate !== true) return decision + const narration = this.narration(agent.session, pending.active) + return narration === undefined + ? decision + : { ...decision, messages: [...decision.messages, narration] } + }) ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close boundary lifetime') ctx.systemPrompt.section({ @@ -424,13 +435,13 @@ export class PlanModeService extends Service { } session.append('plan/mode', { active }) this.pendingIntents.delete(session) - this.narrate(session, active) + const narration = this.narration(session, active) + if (narration !== undefined) agent.inject(narration) return 'committed' } /** Flush one pending selection before the next request assembly. */ - private onBoundary(agent: Agent): void { - const session = agent.session + private onBoundary(session: Session): void { const pending = this.pendingIntents.get(session) if (pending === undefined) return const target = pending.active @@ -442,20 +453,19 @@ export class PlanModeService extends Service { // Delete only after append succeeds so a later boundary can retry a failed // durable write. this.pendingIntents.delete(session) - if (pending.narrate) this.narrate(session, target) } - /** Tell the model about a user switch when the last logged header described the other mode. */ - private narrate(session: Session, target: boolean): void { + /** Build a user-switch notice when the last logged header described the other mode. */ + private narration(session: Session, target: boolean): UserMessage | undefined { const told = planModeAtLastHeader(session.events) if (told === undefined || told === target) return const text = target ? 'The user switched this session to plan mode.' : 'The user switched this session back to the default mode.' - session.append('user/message', createUserMessage({ + return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'plan-mode' }, - }), { surfaceOp: 'append' }) + }) } } diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 31f1ce66a0..51b98bf727 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -71,8 +71,7 @@ describe('plan mode through the agent loop', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock' }) - // Selected while idle: the pending intent flushes at the first - // in-turn agent/step seam, before the first assembly. + // Selected while idle: the mode commits immediately, before the first assembly. ctx.planMode.set(agent, true) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'explore the repo' }], source: { kind: 'user' } })) diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 944ef25061..de0080bee5 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' import UserInteractionService, { @@ -21,15 +21,22 @@ const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig * Drives the REAL plugin: mounts `dsh-plan-mode` beside real `SystemPrompt` and * `ToolRegistry` services, with fake Agents carrying real `Session`s and a * real scoped `agent.ctx` minted through `createScope`. - * Request boundaries are simulated by dispatching the real prompt-admission - * and between-step seams used by the loop. + * Request boundaries are simulated by dispatching the real pre-step waterfall + * and the following `step/start` session event used by the loop. */ async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise { // A live store session when a store is mounted (the command executor logs // lifecycle events through it); bare otherwise (fold/tool-only benches). const session = new Session(SessionId(id)) - const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session } + const agent = { + id: SessionId(id), + session, + options: {}, + inject(message: UserMessage) { + session.append('user/message', message, { surfaceOp: 'append' }) + }, + } as unknown as Agent & { session: Session } let scoped!: Context await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, { inject: ['tools'], @@ -56,24 +63,30 @@ async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise { } /** - * Dispatch either prompt admission or the between-step checkpoint. + * Dispatch pre-step processing and optionally its following step-start commit. */ -async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'turn/start' | 'step/end'): Promise { +async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'pre-step' | 'step-start'): Promise { const events = agentEvents(ctx, agent) - if (type === 'turn/start') { - const message = createUserMessage({ - content: [{ type: 'text', text: 'boundary probe' }], - source: { kind: 'user' }, - }) - await events.waterfall( - 'agent/prompt-submit', - [message], - new AbortController().signal, - () => Promise.resolve({ kind: 'allow', messages: [message] }), - ) - return + const message = createUserMessage({ + content: [{ type: 'text', text: 'boundary probe' }], + source: { kind: 'user' }, + }) + const signal = new AbortController().signal + const decision = await events.waterfall( + 'agent/pre-step', + [message], + { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [message] }), + ) + if (decision.kind === 'enter') { + for (const message of decision.messages.slice(1)) { + agent.session.append('user/message', message, { surfaceOp: 'append' }) + } + } + if (type === 'step-start') { + const event = agent.session.append('step/start', { turn: 1, step: 1 }) + ctx.emit('session/event', agent.session, event) } - await events.serial('agent/step', 1, 2, new AbortController().signal) } /** Open a turn so a selection queues for the boundary flush (the mid-turn shape). */ @@ -210,7 +223,7 @@ describe('ctx.planMode: get/set', () => { expect(ctx.planMode.set(agent, false)).toBe('committed') expect(foldPlanMode(agent.session.events)).toBe(false) // A later boundary finds nothing pending — no double append. - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(agent.session.events.filter(event => event.type === 'plan/mode')).toHaveLength(2) }) @@ -236,23 +249,22 @@ describe('ctx.planMode: get/set', () => { }) describe('the boundary flush', () => { - it('does not flush at prompt admission — the seam is pre-turn, so the first step boundary lands it', async () => { + it('does not flush during pre-step and commits from the following step/start', async () => { const ctx = await setup() const agent = await agentWithSession(ctx) openTurn(agent.session) ctx.planMode.set(agent, true) - // Prompt admission runs before any turn opens; a plan/mode appended there - // would sit outside the turn. The pending intent survives admission and - // the in-turn agent/step boundary flushes it before the request derives. - await boundary(ctx, agent, 'turn/start') + // Pre-step only composes narration. The pending intent survives until the + // turn-enclosed step/start event commits it before request assembly. + await boundary(ctx, agent, 'pre-step') expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(foldPlanMode(agent.session.events)).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true }) }) - it('skips the flush after the plugin fiber is disposed (a captured wrapper must not write into a dead service)', async () => { + it('removes the step/start flush when the plugin fiber is disposed', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) @@ -260,32 +272,9 @@ describe('the boundary flush', () => { const agent = await agentWithSession(ctx) openTurn(agent.session) ctx.planMode.set(agent, true) - // A listener captured in the same dispatch snapshot keeps the plan-mode - // callback alive across the unload; the resumed wrapper must not append - // through the disposed service. Registered prepended AFTER the plugin so - // it runs before plan-mode's own prepended flush. - ctx.on('agent/step', async () => { - await fiber.dispose() - }, { prepend: true }) - await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal) - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) - }) - - it('skips the step-seam flush after the plugin fiber is disposed (a captured listener must not write into a dead service)', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG) - const agent = await agentWithSession(ctx) - openTurn(agent.session) - ctx.planMode.set(agent, true) - // Serial dispatch captures its listener list up front; prepending after - // the plugin puts this listener ahead of the plugin's own prepended one, - // so the plugin's captured callback still runs after the disposal below. - ctx.on('agent/step', async () => { - await fiber.dispose() - }, { prepend: true }) - await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal) + await fiber.dispose() + const event = agent.session.append('step/start', { turn: 1, step: 1 }) + ctx.emit('session/event', agent.session, event) expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) }) @@ -293,7 +282,7 @@ describe('the boundary flush', () => { const ctx = await setup() const agent = await agentWithSession(ctx) ctx.planMode.set(agent, true) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(foldPlanMode(agent.session.events)).toBe(true) }) @@ -304,7 +293,7 @@ describe('the boundary flush', () => { openTurn(agent.session) ctx.planMode.set(agent, true) ctx.planMode.set(agent, false) - await boundary(ctx, agent, 'turn/start') + await boundary(ctx, agent, 'pre-step') expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -313,7 +302,7 @@ describe('the boundary flush', () => { const ctx = await setup() const agent = await agentWithSession(ctx) ctx.planMode.set(agent, true) - await boundary(ctx, agent, 'turn/start') + await boundary(ctx, agent, 'pre-step') expect(noticeTexts(agent.session)).toEqual([]) }) @@ -322,9 +311,9 @@ describe('the boundary flush', () => { const agent = await agentWithSession(ctx) header(agent.session) ctx.planMode.set(agent, true) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.']) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.']) }) @@ -334,7 +323,7 @@ describe('the boundary flush', () => { agent.session.append('plan/mode', { active: true }) header(agent.session) ctx.planMode.set(agent, false) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(noticeTexts(agent.session)).toEqual(['The user switched this session back to the default mode.']) }) @@ -345,7 +334,7 @@ describe('the boundary flush', () => { header(agent.session) agent.session.append('plan/mode', { active: false }) ctx.planMode.set(agent, true) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(foldPlanMode(agent.session.events)).toBe(true) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -365,19 +354,19 @@ describe('the boundary flush', () => { if (type === 'plan/mode') throw new Error('backend gone') return (original as (...args: unknown[]) => unknown)(type, ...rest) }) as unknown) as typeof agent.session.append - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(warn).toHaveBeenCalledOnce() // The failed flush re-parks the intent (cleared only after a landed // append), so the next healthy boundary converges the log with the // picker's optimistic state instead of dropping the switch forever. expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) agent.session.append = original - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(foldPlanMode(agent.session.events)).toBe(true) expect(ctx.planMode.get(agent).pending).toBeUndefined() }) - it('prompt admission never appends, so a broken backend surfaces only at the step boundary', async () => { + it('pre-step never appends, so a broken backend surfaces only at step/start', async () => { const ctx = await setup() const warn = vi.fn() ctx.logger.warn = warn as never @@ -389,9 +378,9 @@ describe('the boundary flush', () => { if (type === 'plan/mode') throw new Error('backend gone') return (original as (...args: unknown[]) => unknown)(type, ...rest) }) as unknown) as typeof agent.session.append - await boundary(ctx, agent, 'turn/start') + await boundary(ctx, agent, 'pre-step') expect(warn).not.toHaveBeenCalled() - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(warn).toHaveBeenCalledOnce() expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) }) @@ -752,7 +741,7 @@ describe('exit_plan_mode', () => { // step's end, so the plan policy covers any remaining call of the SAME batch. expect(foldPlanMode(agent.session.events)).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false }) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(foldPlanMode(agent.session.events)).toBe(false) expect(asked).toHaveLength(1) expect(asked[0]?.agent).toBe(agent) @@ -820,7 +809,7 @@ describe('exit_plan_mode', () => { const assembly = await ctx.systemPrompt.assemble({ agent }) expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true) expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(foldPlanMode(agent.session.events)).toBe(false) const afterExit = await ctx.systemPrompt.assemble({ agent }) expect(afterExit.tools).toEqual(assembly.tools) @@ -831,7 +820,7 @@ describe('exit_plan_mode', () => { const { ctx, agent } = await setupWithReview({ selected: ['Approve'] }) header(agent.session) await callExit(ctx, agent) - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(foldPlanMode(agent.session.events)).toBe(false) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -1024,7 +1013,7 @@ describe('HMR disposal', () => { expect(ctx.get('planMode')).toBeUndefined() expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined() expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('plan:policy') - await boundary(ctx, agent, 'step/end') + await boundary(ctx, agent, 'step-start') expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) }) }) diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 1000d0e78a..4030317f0e 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -42,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') const session = new Session(id) return { - id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx, + id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx, send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } } @@ -249,7 +249,7 @@ describe('pty-local plugin shape', () => { const session = ctx.sessions.create(SessionId('mode-owner')) const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { - id: session.id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx: ownerFiber.ctx, + id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx: ownerFiber.ctx, send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) @@ -292,7 +292,7 @@ describe('pty-local plugin shape', () => { const session = ctx.sessions.create(SessionId('pending-mode-owner')) const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { - id: session.id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx: ownerFiber.ctx, + id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx: ownerFiber.ctx, send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 96d59367ec..57e412eac4 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) const session = new Session(id) return { - id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx: scope.ctx, + id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx: scope.ctx, send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 1b885e1c5e..61e13dc9da 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -26,7 +26,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { id, options: {}, session, - inbox: new Inbox(session), + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx: scopeFiber.ctx, send: () => {}, diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts index e49c652f1d..1df5ba20be 100644 --- a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -43,7 +43,7 @@ function agent(ctx: Context, cwd: string): Agent { id, options: {}, session, - inbox: new Inbox(session), + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 76cd72f2e8..757b1be347 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -39,7 +39,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { id, options: {}, session, - inbox: new Inbox(session), + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index ca7bc6a3e8..37cd856746 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const session = new Session(id) const value: Agent = { - id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx: scope.ctx, + id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx: scope.ctx, send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index 13bd8b9e23..6af2f6a14f 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const session = new Session(id) const agent: Agent = { - id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx: scope.ctx, + id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx: scope.ctx, send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) diff --git a/packages/session-persistence/session-checkpoint-policy/README.i18n.yaml b/packages/session-persistence/session-checkpoint-policy/README.i18n.yaml index 2ec207d786..5e55be6a54 100644 --- a/packages/session-persistence/session-checkpoint-policy/README.i18n.yaml +++ b/packages/session-persistence/session-checkpoint-policy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-persistence/session-checkpoint-policy/README.md -README.md: aba0f00a2960eca3db06dda267cc954f602c1b09 -README.zh.md: ae403302f86b9fadc964c7963d3a454dd14180d3 +README.md: 57be9e78236775c39971c925f4972398c0b20a97 +README.zh.md: 66508382783c36c41be65afc758398d406c3a58f diff --git a/packages/session-persistence/session-checkpoint-policy/README.md b/packages/session-persistence/session-checkpoint-policy/README.md index aba0f00a29..57be9e7823 100644 --- a/packages/session-persistence/session-checkpoint-policy/README.md +++ b/packages/session-persistence/session-checkpoint-policy/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Semantic durability policy for persisted agents. It checkpoints the event-sourced session before a model adapter receives a request, before a top-level tool body may produce an external side effect, and at each `agent/step` boundary so the preceding response and ordered tool results are durable before the next request. +Semantic durability policy for persisted agents. It checkpoints the event-sourced session before a model adapter receives a request, before a top-level tool body may produce an external side effect, and at each `agent/pre-step` boundary so the preceding response and ordered tool results are durable before the next request. ## Plugin (namespace: `session-checkpoint-policy`) @@ -18,7 +18,7 @@ This zero-config function plugin consumes `ctx.sessions`, `ctx.llm`, `ctx.tools` Persistence and checkpoint scheduling are intentionally separate Cordis plugins. A persistence backend eagerly writes `session/event` appends and makes each requested `session/flush` an observation barrier; this policy chooses the request, tool-dispatch, and next-step barriers. Loading a backend without this policy is valid, but a crash may lose the latest eagerly buffered events. First-party persisted apps and runtimes mount both plugins explicitly; a specialized deployment may deliberately omit or replace the policy. -The policy wraps `llm/stream` lazily, so the downstream stream is not constructed until the live session's buffered request events are durable. It wraps `tools/execute` after pre-execute policy and guards; a top-level tool body runs only after its recorded call is durable. If cancellation lands while that flush is pending, the wrapper returns the canonical `ABORTED_BEFORE_DISPATCH` result without entering the tool body. Nested tool dispatches reuse the outer model-visible call's checkpoint. `agent/step` persists the preceding response/result batch before request derivation. +The policy wraps `llm/stream` lazily, so the downstream stream is not constructed until the live session's buffered request events are durable. It wraps `tools/execute` after pre-execute policy and guards; a top-level tool body runs only after its recorded call is durable. If cancellation lands while that flush is pending, the wrapper returns the canonical `ABORTED_BEFORE_DISPATCH` result without entering the tool body. Nested tool dispatches reuse the outer model-visible call's checkpoint. `agent/pre-step` persists the preceding response/result batch before request derivation. Checkpoint rejection is fail-closed at the model and tool boundaries: neither the adapter nor the top-level tool body runs. A step-boundary rejection fails the turn before another request starts. Concurrent tool checkpoints share the session store's serialized persistence drain and cannot duplicate sequence numbers. diff --git a/packages/session-persistence/session-checkpoint-policy/README.zh.md b/packages/session-persistence/session-checkpoint-policy/README.zh.md index ae403302f8..6650838278 100644 --- a/packages/session-persistence/session-checkpoint-policy/README.zh.md +++ b/packages/session-persistence/session-checkpoint-policy/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -已持久化的 agent(智能体)的语义持久性策略。它会在模型适配器收到请求前、顶层工具正文可产生外部副作用前,以及每个 `agent/step` 边界为事件溯源会话创建检查点,使前一响应与有序工具结果在下一个请求前已持久化。 +已持久化的 agent(智能体)的语义持久性策略。它会在模型适配器收到请求前、顶层工具正文可产生外部副作用前,以及每个 `agent/pre-step` 边界为事件溯源会话创建检查点,使前一响应与有序工具结果在下一个请求前已持久化。 ## 插件(命名空间:`session-checkpoint-policy`) @@ -18,7 +18,7 @@ 持久化与检查点调度刻意拆分为独立 Cordis 插件。持久化后端会主动写入追加的 `session/event`,并把每个已请求 `session/flush` 变成观测屏障;该策略选择请求、工具分派和下一步骤屏障。不带此策略加载后端是有效的,但崩溃可能丢失最新的已缓冲事件。第一方持久化应用和运行时显式挂载两个插件;专用部署可以刻意省略或替换策略。 -策略延迟包装 `llm/stream`,因此下游流只会在活动会话中缓冲的请求事件已持久化后构造。它在预执行策略和防护机制之后包装 `tools/execute`;只有在已记录调用已持久化后,顶层工具正文才会运行。如果取消在 flush 等待期间到达,包装层会返回规范的 `ABORTED_BEFORE_DISPATCH` 结果,不进入工具正文。嵌套工具分派重用外层模型可见调用的检查点。`agent/step` 在派生请求前持久化前一响应/结果批次。 +策略延迟包装 `llm/stream`,因此下游流只会在活动会话中缓冲的请求事件已持久化后构造。它在预执行策略和防护机制之后包装 `tools/execute`;只有在已记录调用已持久化后,顶层工具正文才会运行。如果取消在 flush 等待期间到达,包装层会返回规范的 `ABORTED_BEFORE_DISPATCH` 结果,不进入工具正文。嵌套工具分派重用外层模型可见调用的检查点。`agent/pre-step` 在派生请求前持久化前一响应/结果批次。 在模型和工具边界,检查点被拒绝时会按失败即阻止原则处理:适配器和顶层工具正文都不运行。步骤边界处的检查点被拒绝会在另一个请求开始前使轮次失败。并发工具检查点共享会话存储的串行持久化排空流程,不会产生重复的序列号。 diff --git a/packages/session-persistence/session-checkpoint-policy/src/index.ts b/packages/session-persistence/session-checkpoint-policy/src/index.ts index 9108a29c14..c26a65e8ad 100644 --- a/packages/session-persistence/session-checkpoint-policy/src/index.ts +++ b/packages/session-persistence/session-checkpoint-policy/src/index.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import type { Session } from '@deepseek-ai/dsh-session' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionResult } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-agent' +import type { PreStepDecision } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-session-persistence' /** Cordis plugin name used by Loader diagnostics. */ @@ -76,5 +76,8 @@ export function apply(ctx: Context): void { // Before each request, persist everything committed by the preceding step; // the first step's call is an intentional no-op beyond any prompt intake. - ctx.on('agent/step', (agent): Promise => ctx.sessions.flush(agent.session)) + ctx.on('agent/pre-step', async (agent, _messages, _context, next): Promise => { + await ctx.sessions.flush(agent.session) + return next() + }) } diff --git a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index 0b10ae591f..b619871156 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -220,13 +220,17 @@ describe('session-checkpoint-policy tool and step boundaries', () => { expect(flushes).toBe(0) }) - it('checkpoints before the next agent step', async () => { + it('checkpoints during pre-step processing', async () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('post-step')) const agent = { session } as Agent const flushed: string[] = [] ctx.on('session/flush', (current) => { flushed.push(current.id) }) - await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal) + const signal = new AbortController().signal + await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', [], { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter', messages: [] }), + ) expect(flushed).toEqual([session.id]) }) }) diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 9d706de673..590368645a 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md -README.md: d8e00bc839358f58cd83bfa9b28eed09dd407bce -README.zh.md: 6c0df1d6e38c99ce64cadeb668bbf0ad7b3029e3 +README.md: 05e7f7f4d08c52c1bfa0b7f67a618231b29f3e43 +README.zh.md: e541891918c58edbcb2924d7382639a703d9148c diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index d8e00bc839..05e7f7f4d0 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -8,9 +8,9 @@ Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools ## Catalog lifecycle -At every `agent/step`, the plugin calls `ctx.skills.snapshot()` for the calling session's cwd, forwards the step abort signal to discovery, applies exact `skill` tool visibility, and renders the ordered `name` and `description` entries. When no prior catalog exists and that view is non-empty, it injects an initial durable user-role `` before the request. Catalog messages contain only those summaries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. +At every eligible `agent/pre-step`, the plugin calls `ctx.skills.snapshot()` for the calling session's cwd, forwards the pre-step abort signal to discovery, applies exact `skill` tool visibility, and renders the ordered `name` and `description` entries. When no prior catalog exists and that view is non-empty, it adds an initial durable user-role `` to a downstream `enter` decision. Catalog messages contain only those summaries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. -The digest covers the exact rendered text between the `` tags. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest recognizable visible catalog message it sourced. When the digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry on the next step. If no prior catalog exists and the current view is empty, no tombstone is necessary. +The digest covers the exact rendered text between the `` tags. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest recognizable visible catalog message it sourced. When the digest changes, an entering pre-step receives a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry at the next pre-step. If no prior catalog exists and the current view is empty, no tombstone is necessary. The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned. @@ -28,7 +28,7 @@ Resource guidance resolves only paths or URLs explicitly referenced by the instr An unresolved name reports that the skill is unknown or no longer available. Invalid names and skills whose `invocation.modelInvocable` is `false` produce distinct error results. `invocation.userInvocable` does not restrict this model-facing surface. -Tool execution does not call `agent.inject()`. Its freshly loaded result is already recorded as the tool result and becomes available to the next model step without duplicating the body as synthetic context. Only the catalog projection injects replacement summaries. +Tool execution does not add a synthetic context message. Its freshly loaded result is already recorded as the tool result and becomes available to the next model step without duplicating the body. Only the catalog projection adds replacement summaries. ## Model Experience diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index 6c0df1d6e3..e541891918 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -8,9 +8,9 @@ ## 目录生命周期 -每次 `agent/step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将步骤中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会在请求之前注入初始的持久用户角色 ``。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。 +每次符合条件的 `agent/pre-step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将 pre-step 中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会向下游 `enter` 决策添加初始的持久用户角色 ``。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。 -该 digest 覆盖 `` 标签之间精确渲染的文本。插件从后向前扫描持久会话事件且不复制,并以自身发布的最新一条可识别且仍可见的目录消息作为比较基线。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 +该 digest 覆盖 `` 标签之间精确渲染的文本。插件从后向前扫描持久会话事件且不复制,并以自身发布的最新一条可识别且仍可见的目录消息作为比较基线。digest 变化时,进入步骤的 pre-step 会收到一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一次 pre-step 重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。 @@ -28,7 +28,7 @@ 无法解析的名称会报告 skill 未知或已不可用。无效名称和 `invocation.modelInvocable` 为 `false` 的 skill 会产生不同的错误结果。`invocation.userInvocable` 不限制这个面向模型的接口。 -工具执行不调用 `agent.inject()`。新加载的结果已作为工具结果记录,并在下一个模型步骤可用,无需将正文重复为合成上下文。只有目录投影会注入替换摘要。 +工具执行不会添加合成上下文消息。新加载的结果已作为工具结果记录,并在下一个模型步骤可用,无需重复正文。只有目录投影会添加替换摘要。 ## 模型体验 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 82741420c1..b343fee8b0 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -7,7 +7,7 @@ import { createHash } from 'node:crypto' import type { Context } from 'cordis' import z from 'schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' @@ -134,22 +134,45 @@ export function apply(ctx: Context, config: Config = {}): void { // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. - ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise => { + ctx.on('agent/pre-step', async ( + agent: Agent, + _messages, + { signal }, + next, + ): Promise => { + const decision = await next() + if (decision.kind === 'reject') return decision + signal.throwIfAborted() const toolVisible = ctx.tools.get(skillTool.name, agent) === registeredSkillTool const snapshot = toolVisible ? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal }) : { skills: [], complete: true } signal.throwIfAborted() - if (!snapshot.complete) return + if (!snapshot.complete) return decision const skills = snapshot.skills.filter(isModelInvocable) const digest = catalogDigest(skills, catalogDescriptionMaxLength) const history = catalogHistory(agent) - if (history.visibleDigest === digest) return - if (!history.published && skills.length === 0) return + const existing = catalogMessage(decision.messages) + if (history.visibleDigest === digest) { + return existing === undefined + ? decision + : { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.id) } + } + if (existing !== undefined && catalogContentDigest(existing.content) === digest) return decision + if (!history.published && skills.length === 0) { + return existing === undefined + ? decision + : { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.id) } + } const catalog = history.published ? renderCatalogUpdate(skills, catalogDescriptionMaxLength) : renderCatalogMessage(skills, catalogDescriptionMaxLength) - agent.session.append('user/message', catalog, { surfaceOp: 'append' }) + return { + kind: 'enter', + messages: existing === undefined + ? [...decision.messages, catalog] + : decision.messages.map(message => message.id === existing.id ? catalog : message), + } }) } @@ -281,6 +304,13 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool return { published } } +function catalogMessage(messages: readonly UserMessage[]): UserMessage | undefined { + return messages.find(message => + message.source.kind === 'plugin' + && message.source.plugin === PLUGIN_SOURCE.plugin + && catalogContentDigest(message.content) !== undefined) +} + function catalogContentDigest(content: UserMessage['content']): string | undefined { if (content.length !== 1 || content[0]?.type !== 'text') return undefined const text = content[0].text diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index cf9ed4b1c7..9ddf6095a1 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -44,7 +44,7 @@ function agentForCwd(cwd: string): Agent { id, options: {}, session, - inbox: new Inbox(session), + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', send: () => {}, followup: () => {}, @@ -60,7 +60,7 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent { id: SessionId(id), options: {}, session, - inbox: new Inbox(session), + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'running', ctx: new Context(), send: () => {}, @@ -81,7 +81,18 @@ function openMessageTurn(session: Session, turn = 1): void { } async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): Promise { - await agentEvents(ctx, agent).serial('agent/step', turn, step, new AbortController().signal) + const signal = new AbortController().signal + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + [], + { turn, step, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) + if (decision.kind === 'enter') { + for (const message of decision.messages) { + agent.session.append('user/message', message, { surfaceOp: 'append' }) + } + } } function catalogMessages(session: Session): Extract[] { @@ -102,7 +113,17 @@ async function composePrefix(ctx: Context, cwd: string, signal = new AbortContro } async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise { - await agentEvents(ctx, agent).serial('agent/step', 1, 1, signal) + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + [], + { turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) + if (decision.kind === 'enter') { + for (const message of decision.messages) { + agent.session.append('user/message', message, { surfaceOp: 'append' }) + } + } return agent.session.deriveMessages() } @@ -197,16 +218,30 @@ describe('dsh-tool-skill', () => { source: 'runtime', content: 'User-only body.', }) - ctx.on('agent/step', (agent) => { - agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'later contribution' }], - source: { kind: 'plugin', plugin: 'later-contribution' }, - }), { surfaceOp: 'append' }) + ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => { + const decision = await next() + if (decision.kind === 'reject') return decision + return { + ...decision, + messages: [ + ...decision.messages, + createUserMessage({ + content: [{ type: 'text', text: 'later contribution' }], + source: { kind: 'plugin', plugin: 'later-contribution' }, + }), + ], + } }) const prefix = await composePrefix(ctx, '/workspace') expect(prefix).toEqual([ + { + id: expect.any(String) as unknown, + role: 'user', + content: [{ type: 'text', text: 'later contribution' }], + source: { kind: 'plugin', plugin: 'later-contribution' }, + }, { id: expect.any(String) as unknown, role: 'user', @@ -228,14 +263,8 @@ describe('dsh-tool-skill', () => { ].join('\n'), }], }, - { - id: expect.any(String) as unknown, - role: 'user', - content: [{ type: 'text', text: 'later contribution' }], - source: { kind: 'plugin', plugin: 'later-contribution' }, - }, ]) - const rendered = JSON.stringify(prefix[0]) + const rendered = JSON.stringify(prefix[1]) expect(rendered).not.toContain('whenToUse') expect(rendered).not.toContain('secret-source') expect(rendered).not.toContain('/secret/path') diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 3f5fd22db1..d88e596e67 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -23,7 +23,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { id, options: {}, session, - inbox: new Inbox(session), + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle' as const, ctx: scopeFiber.ctx, send: () => {}, diff --git a/packages/tasks/tool-tasks/README.i18n.yaml b/packages/tasks/tool-tasks/README.i18n.yaml index 646ac8ca6c..bdd840f0bd 100644 --- a/packages/tasks/tool-tasks/README.i18n.yaml +++ b/packages/tasks/tool-tasks/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/tasks/tool-tasks/README.md -README.md: 276ca3f8284b366bee54e585297c3a033a65c8d1 -README.zh.md: 823c25c3e0c7c208ffef9c5be5b2e4ef7e96bad8 +README.md: 6e8e889c2330d6991cb384674b011e4d2e988268 +README.zh.md: 9e54e2de69f36d5d5b70094cab12e5503eaeb5fd diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 276ca3f828..6e8e889c23 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -18,7 +18,7 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill` ## Completion notices -An unreported completion injects `background task (: