From 1a09174987e565a21eb3368224942513ecb373f7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 31 Jul 2026 22:52:18 +0800 Subject: [PATCH] refactor(goal): persist state with domain events --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 4 +- ...ied-send-and-coalesced-user-messages.zh.md | 4 +- ...-07-31-goal-owned-durable-events.i18n.yaml | 6 + .../2026-07-31-goal-owned-durable-events.md | 33 +++ ...2026-07-31-goal-owned-durable-events.zh.md | 33 +++ .../2026-07-16-harness-level-loop.i18n.yaml | 4 +- .../feature/2026-07-16-harness-level-loop.md | 8 +- .../2026-07-16-harness-level-loop.zh.md | 8 +- .../2026-07-19-human-goal-command.i18n.yaml | 4 +- .../feature/2026-07-19-human-goal-command.md | 2 +- .../2026-07-19-human-goal-command.zh.md | 2 +- ...rsisted-same-session-goal-domain.i18n.yaml | 4 +- ...7-19-persisted-same-session-goal-domain.md | 14 +- ...9-persisted-same-session-goal-domain.zh.md | 14 +- docs/cordis-catalog/events.md | 7 +- docs/core-data-structures/goal.i18n.yaml | 4 +- docs/core-data-structures/goal.md | 16 +- docs/core-data-structures/goal.zh.md | 16 +- docs/persistence-catalog.md | 49 ++++- docs/tool-catalog.md | 2 +- .../client/connection/src/client/fixture.ts | 63 ++---- .../client/connection/tests/fixture.spec.ts | 17 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/goal/command-goal/README.i18n.yaml | 4 +- packages/goal/command-goal/README.md | 8 +- packages/goal/command-goal/README.zh.md | 8 +- .../command-goal/tests/command-goal.spec.ts | 2 +- .../goal-session/tests/goal-session.spec.ts | 4 +- .../goal/goal-session/tests/invariant.spec.ts | 31 +-- packages/goal/goal/README.i18n.yaml | 4 +- packages/goal/goal/README.md | 14 +- packages/goal/goal/README.zh.md | 14 +- packages/goal/goal/src/domain.ts | 26 ++- packages/goal/goal/src/fold.ts | 75 +------ packages/goal/goal/src/index.ts | 99 ++++----- packages/goal/goal/src/invariant.ts | 1 - packages/goal/goal/src/render.ts | 21 -- packages/goal/goal/src/types.ts | 2 +- packages/goal/goal/tests/goal.e2e.ts | 20 +- packages/goal/goal/tests/goal.spec.ts | 200 ++++-------------- packages/goal/goal/tests/invariant.spec.ts | 47 +--- packages/goal/goal/tests/projection.spec.ts | 68 ++---- packages/goal/tool-goal/README.i18n.yaml | 4 +- packages/goal/tool-goal/README.md | 6 +- packages/goal/tool-goal/README.zh.md | 6 +- packages/ui/tui/tests/tui.spec.ts | 18 +- scripts/gen-tool-catalog.ts | 2 +- 48 files changed, 354 insertions(+), 650 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md create mode 100644 .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.zh.md delete mode 100644 packages/goal/goal/src/render.ts 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 361c27292d..de7f299504 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: d771baf8854d02d2945ecf05db93672ff1de5645 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 7fd65cb433071fdaaa65b65e965da0757b02f31f +2026-07-22-unified-send-and-coalesced-user-messages.md: c9e4a2195f01b72f1d0c16136c666f2e00e484d0 +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: fc6d26b421ba30cab0937c6e3a7ef3a41c83d654 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 d771baf885..c9e4a2195f 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 @@ -18,7 +18,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj **context/message is gone.** Injected context uses one `UserMessage` value in the inbox and becomes a `user/message` event if admitted; 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. -**Goal replay disambiguates by round, not type.** A goal state change commits as a round-zero goal-sourced message in a durable inbox insertion; a positive round advances only from an admitted continuation `user/message`. If the state-change message is later admitted, replay checks that its id, content, and typed source match the insertion without applying the change again. +**Goal continuation attribution uses positive rounds.** Goal lifecycle state commits through the domain-owned `goal/change` event defined by the later [goal-owned durable event decision](2026-07-31-goal-owned-durable-events.md). A positive round advances only from an admitted continuation `user/message`; goal persistence does not use injection or inbox state. **`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. @@ -41,7 +41,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj ## Consequences -The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One identified message value serves prompts, injected context, and goal rounds, so every "human prompt?" check simplifies to a `source` test. The `Agent` contract remains an interface, so alternate implementations and object-literal test fakes implement the same minimal structural surface. Goal mutations fold from durable inbox insertions, while positive rounds fold from admitted `user/message` events. 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. +The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One identified message value serves prompts, injected context, and goal rounds, so every "human prompt?" check simplifies to a `source` test. The `Agent` contract remains an interface, so alternate implementations and object-literal test fakes implement the same minimal structural surface. Positive goal rounds fold from admitted `user/message` events, while goal lifecycle state remains outside the delivery surface. 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 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. 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 7fd65cb433..fc6d26b421 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 @@ -18,7 +18,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` **context/message 已移除。** 注入的上下文在 inbox 中使用同一个 `UserMessage` 值,并在获准时成为 `user/message` 事件;上下文生产方显式提供合适的非 `user` 类别 `source`,类型化 source 变体携带所有特定于领域的持久来源信息。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。 -**goal 回放靠 Round 而非类型来区分。** 一次 goal 状态变更会作为 Round 为 0、来源为 goal 的消息,在持久 inbox 插入项中提交;正数 Round 只从已准入的继续执行 `user/message` 推进。如果状态变更消息随后获准,回放会检查其 id、内容和类型化 source 是否与插入项一致,而不会再次应用变更。 +**Goal 继续执行归属使用正数 Round。** Goal 生命周期状态通过后续 [Goal 自有持久事件决策](2026-07-31-goal-owned-durable-events.md)定义的领域自有 `goal/change` 事件提交。正数 Round 只从已准入的继续执行 `user/message` 推进;goal 持久化不使用注入或 inbox 状态。 **`send` 不返回标识。** 调用方已经持有完整消息及其不透明的 `MessageId`;消息的创建与冻结由[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)负责,而不是由路由负责。 @@ -41,7 +41,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` ## 后果 -投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。同一个带标识消息值同时服务提示词、注入的上下文和 Goal Round,因此每一处“是否人类提示词?”检查都简化为一次 `source` 判断。`Agent` 契约仍是接口,因此其他实现和对象字面量形式的测试替身只需实现同一个最小结构接口。goal 变更从持久 inbox 插入项折叠,而正数 Round 从已准入的 `user/message` 事件折叠。空闲注入会保持待处理,不打开轮次也不运行模型;后续会唤醒的投递在 pre-step 将其放入进入步骤的批次时,它才成为 `user/message`。 +投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。同一个带标识消息值同时服务提示词、注入的上下文和 Goal Round,因此每一处“是否人类提示词?”检查都简化为一次 `source` 判断。`Agent` 契约仍是接口,因此其他实现和对象字面量形式的测试替身只需实现同一个最小结构接口。正数 Goal Round 从已准入的 `user/message` 事件折叠,而 goal 生命周期状态位于投递接口之外。空闲注入会保持待处理,不打开轮次也不运行模型;后续会唤醒的投递在 pre-step 将其放入进入步骤的批次时,它才成为 `user/message`。 `wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可领取的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算完全停稳。每次插入与退出都会发布对应的实时通知,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理消息的表示方式,使持久 splice 与实时事件保持可关联,既无需维护第二个 steering 包装层,也避免数据发生分歧。后续的[已领取 pre-step inbox 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.md)决策保留通过 `MessageId` 寻址的实时队列变更,并把单消息生命周期通知与持久的整体队列 splice 投影分离。 diff --git a/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml new file mode 100644 index 0000000000..589ecbf5fd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.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-goal-owned-durable-events.md +2026-07-31-goal-owned-durable-events.md: ac0358469958319f7629adb5e96845b7e0013297 +2026-07-31-goal-owned-durable-events.zh.md: 9b45cdd7990bfc1fdfbb63650e40b41e5ba2918d diff --git a/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md new file mode 100644 index 0000000000..ac03584699 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md @@ -0,0 +1,33 @@ +# Agent Note: Goal-owned durable events + +Status: implemented + +English | [中文](2026-07-31-goal-owned-durable-events.zh.md) + +## Problem + +Goal state and inbox state have different lifecycles. A goal mutation must survive restart and fork whether or not any related model context is admitted, while an inbox message may be edited, claimed, rejected, or discarded as part of step scheduling. Encoding a goal mutation inside a round-zero inbox message made queue placement the domain commit point and required replay to reconcile insertion, admission, message identity, source metadata, and rendered content. + +The goal domain needs durable state, but it does not need ownership of pending model input. Continuation scheduling still needs the inbox; goal persistence does not. + +## Decision + +`@deepseek-ai/dsh-goal` owns a durable `goal/change` session event. Each event carries the complete post-mutation goal snapshot or a revisioned clear tombstone. `GoalService` appends that event synchronously, then emits `goal/changed`; strict replay and the `goal` session projection fold only `goal/change` for lifecycle state. + +`GoalMessageSource` identifies only positive admitted continuation rounds. A matching `user/message` advances `roundsStarted`; ordinary user messages and inbox splice events do not change goal state. The goal package never inserts, claims, removes, or inspects inbox messages. `@deepseek-ai/dsh-goal-session` remains responsible for queuing and tracking its own continuation prompts through the public inbox lifecycle. + +Activation remains process-local. The service associates the synchronously appended event sequence with the requested activation while its cache observes the event; replayed or externally appended changes default to disarmed. The session log remains the only durable authority. + +The domain does not automatically project each mutation into model input. Goal tools return current state, and continuation prompts include the objective and round state when work is actually scheduled. Any future always-visible goal context is a separate context plugin that owns its inbox message rather than a persistence side effect. + +## Alternatives considered + +- **Keep round-zero goal messages as the durable record.** Rejected because it couples domain commits to queue mutation and requires the goal fold to understand claim and admission reconciliation even though queue outcomes cannot roll back domain state. +- **Derive goal state only from model-visible messages.** Rejected because a mutation may be valid and durable without opening a step, and cancellation or policy rejection must not erase it. +- **Store goals in a separate database.** Rejected because the ordered session log already supplies persistence, replay, and fork inheritance without a second atomicity boundary. + +## Consequences + +Goal state is independent of inbox placement and admission. Replay has one mutation path, projections advance directly on `goal/change`, and continuation messages carry only round attribution. The model does not receive a mutation-only `` message; model-visible state appears through goal tools and scheduled continuation prompts. Direct session writers remain trusted and can append malformed changes, which the strict fold and invariant companion reject. + +Focused goal, goal-session, command, TUI, and client-fixture tests pin durable replay, positive-round accounting, inbox independence, projection updates, and restored-session behavior. The keyless process test inspects the persisted `goal/change` event and verifies that creation alone starts no continuation round. diff --git a/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.zh.md b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.zh.md new file mode 100644 index 0000000000..9b45cdd799 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Goal 自有的持久事件 + +Status: implemented + +[English](2026-07-31-goal-owned-durable-events.md) | 中文 + +## 问题 + +Goal 状态与 inbox 状态具有不同的生命周期。无论相关模型上下文是否获准进入步骤,goal 变更都必须在重启与 fork 后保留;inbox 消息则可能在步骤调度期间被编辑、领取、拒绝或丢弃。把 goal 变更编码到 Round 为 0 的 inbox 消息中,会让队列放置成为领域提交点,并迫使回放对账插入、准入、消息标识、来源元数据与渲染内容。 + +Goal 领域需要持久状态,但不需要拥有待处理的模型输入。继续执行调度仍然需要 inbox;goal 持久化不需要。 + +## 决策 + +`@deepseek-ai/dsh-goal` 拥有持久的 `goal/change` 会话事件。每个事件携带变更后的完整 goal 快照,或带修订号的清除墓碑。`GoalService` 同步追加该事件,再发出 `goal/changed`;严格回放与 `goal` 会话投影只折叠 `goal/change` 来获得生命周期状态。 + +`GoalMessageSource` 只标识已准入且为正数的继续执行 Round。匹配的 `user/message` 会推进 `roundsStarted`;普通用户消息与 inbox splice 事件不会改变 goal 状态。Goal 包不会插入、领取、移除或检查 inbox 消息。`@deepseek-ai/dsh-goal-session` 仍通过公开 inbox 生命周期负责排队和跟踪自己的继续执行提示词。 + +激活态仍只存在于进程中。服务在缓存观察事件时,将同步追加的事件序号与目标激活态关联;回放或外部追加的变更默认处于 `disarmed`。会话日志仍是唯一的持久权威。 + +该领域不会自动把每次变更投影为模型输入。Goal 工具返回当前状态;真正调度工作时,继续执行提示词包含目标描述与 Round 状态。未来如果需要始终可见的 goal 上下文,应由独立上下文插件拥有其 inbox 消息,而不是把它作为持久化副作用。 + +## 考虑过的替代方案 + +- **继续以 Round 为 0 的 goal 消息作为持久记录。** 不予采纳,因为这会把领域提交与队列变更绑定,并要求 goal 折叠理解领取和准入对账,尽管队列结果不能回滚领域状态。 +- **只从模型可见消息派生 goal 状态。** 不予采纳,因为变更可以在不打开步骤的情况下有效且持久,取消或策略拒绝也不能擦除它。 +- **把 goal 存入独立数据库。** 不予采纳,因为有序会话日志已经提供持久化、回放与 fork 继承,无需引入第二个原子性边界。 + +## 后果 + +Goal 状态不依赖 inbox 放置与准入。回放只有一条变更路径,投影直接由 `goal/change` 推进,继续执行消息只携带 Round 归属。模型不会收到仅用于变更的 `` 消息;模型可见状态来自 goal 工具与已调度的继续执行提示词。直接写入会话的插件仍受信任,并且可以追加畸形变更;严格折叠与 invariant 配套模块会拒绝这些变更。 + +聚焦的 goal、goal-session、command、TUI 与 client fixture 测试固定持久回放、正数 Round 计数、inbox 独立性、投影更新和恢复会话行为。无密钥进程测试检查持久的 `goal/change` 事件,并验证仅创建 goal 不会启动继续执行 Round。 diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml index 49a17c9681..56c16358ac 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.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-16-harness-level-loop.md -2026-07-16-harness-level-loop.md: cf449affe77bf18feabbd163cd778f1496870995 -2026-07-16-harness-level-loop.zh.md: 6d1da72e885d65b54dceb235f18dbd3090e844a2 +2026-07-16-harness-level-loop.md: 0cebbcc305b1532d28aeffd6824edd870d42aec6 +2026-07-16-harness-level-loop.zh.md: 911a5990b72e63f6a840b544648f94cadf0d1345 diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md index cf449affe7..0cebbcc305 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md @@ -42,11 +42,11 @@ Time-based `/loop` or scheduled execution is a third policy and is not implement | `@deepseek-ai/dsh-command-goal` | `packages/goal/command-goal/`, human-command producer | Registers `/goal` status, creation, edit, pause, resume, and clear over the goal domain for TUI. | | `@deepseek-ai/dsh-tool-ralph` | `packages/workflow/tool-ralph/`, fixed workflow consumer | Registers `ralph({ objective, maxRounds? })`, validates the fresh structured provider and bounded `RalphRoundReport`, and returns `complete`, `blocked`, or `budget-limited`. | -The detailed contracts live in the [goal-domain](2026-07-19-persisted-same-session-goal-domain.md), [model goal-tools](2026-07-19-model-facing-goal-tools.md), [goal-round driver](2026-07-19-same-session-goal-round-driver.md), [command registry](2026-07-19-plugin-command-registration.md), [human goal-command](2026-07-19-human-goal-command.md), and [Ralph workflow-tool](2026-07-19-fresh-agent-ralph-workflow-tool.md) Agent Notes. +The detailed contracts live in the [goal-domain](2026-07-19-persisted-same-session-goal-domain.md), [goal-owned event](../architecture/2026-07-31-goal-owned-durable-events.md), [model goal-tools](2026-07-19-model-facing-goal-tools.md), [goal-round driver](2026-07-19-same-session-goal-round-driver.md), [command registry](2026-07-19-plugin-command-registration.md), [human goal-command](2026-07-19-human-goal-command.md), and [Ralph workflow-tool](2026-07-19-fresh-agent-ralph-workflow-tool.md) Agent Notes. ### Durable goal state and live authority -One session has at most one current goal. Every mutation commits through the durable `agent/inbox/spliced` insertion produced by `Agent.inject()`, carrying a full versioned snapshot or revisioned clear tombstone. Its queued context becomes model-visible only if later admitted, and discarding it does not roll back the goal. The session log is the only durable source of truth, so normal persistence, resume, and `SessionStore.fork()` carry the goal without a second database or an artificial cancellation record. +One session has at most one current goal. Every mutation commits through a durable `goal/change` event carrying a full versioned snapshot or revisioned clear tombstone; inbox state does not participate. The session log is the only durable source of truth, so normal persistence, resume, and `SessionStore.fork()` carry the goal without a second database or an artificial cancellation record. Durable phases are only `active`, `paused`, `blocked`, and `complete`. A blocked goal carries a required `GoalBlockReason` with a stable lower-kebab-case `code` and a non-empty human-readable `message`; usage limits, round exhaustion, model failures, and policy rejection are reason codes rather than extra lifecycle phases. Separate activation is `armed` or `disarmed` and is never persisted. Creation and explicit resume arm a goal; stop transitions, session start, fork replay, driver replacement, and driver teardown leave it disarmed. @@ -58,7 +58,7 @@ Forked sessions inherit the durable goal prefix because that is the natural repl ### Same-session continuation -The goal-round driver owns at most one pending reservation per exact live agent. It admits a reservation only when the goal is active and armed, the agent is idle, no competing human work exists, pending mutation insertions have passed their durability checkpoint, the exact goal id/revision/round still matches, and downstream pre-step policy accepts it. Its `agent/pre-step` fence checks those facts both before and after downstream listeners, preventing an edit, pause, human message, or unload race from admitting obsolete work. +The goal-round driver owns at most one pending reservation per exact live agent. It admits a reservation only when the goal is active and armed, the agent is idle, no competing human work exists, the latest mutation has passed its durability checkpoint, the exact goal id/revision/round still matches, and downstream pre-step policy accepts it. Its `agent/pre-step` fence checks those facts both before and after downstream listeners, preventing an edit, pause, human message, or unload race from admitting obsolete work. Only an admitted positive-round goal-sourced `user/message` charges a round. A stale reservation is rejected before a turn opens without consuming the cap. A concurrent goal revision wins over settlement from an older round. @@ -122,7 +122,7 @@ The six owning Agent Notes record unit, integration, process, snapshot, cancella - **Aggregate budgets** — `maxGoalRounds` and Ralph `maxRounds` are the only aggregate effort limits. Token, currency, elapsed-time, provider-usage, and per-round price admission policies are absent. - **No persistent autonomous runner** — same-session goal facts persist, but activation and scheduling are process-local and deliberately wait for human input after restore. Ralph runs are foreground and cannot resume after process loss. Background collection, restart recovery, and unattended resident execution are deferred. - **No time scheduler** — interval `/loop`, cron, proactive maintenance, and cloud or desktop scheduling are outside this decision. -- **No generic loop journal or execution-world rewind** — session replay reconstructs model-visible goal history, not prior files, processes, environment, credentials, or external side effects. Ralph treats the current workspace as authority and carries no cross-run journal. +- **No generic loop journal or execution-world rewind** — session replay reconstructs goal history, not prior files, processes, environment, credentials, or external side effects. Ralph treats the current workspace as authority and carries no cross-run journal. - **No goal reflector** — concern events, automatic no-progress heuristics, goal revision by an independent reflector, stuck-pattern detection, and `loop_split` are not implemented. Humans can edit, pause, clear, or resume the goal directly. - **Ralph policy remains narrow** — one round creates one fresh child; within-round fan-out, evaluator/worker role separation, dynamic provider/model selection, and structural recursive-Ralph tool denial need separate policy surfaces. Prompt guidance is not enforcement. - **Ralph does not retry a failed child** — an ordinary failure preserves the failed round and last good handoff, while fatal workflow infrastructure failures can end before that state is available. Retry count, backoff, and richer failure transport need separate policy and seam design. diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md index 6d1da72e88..911a5990b7 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md @@ -42,11 +42,11 @@ Status: implemented | `@deepseek-ai/dsh-command-goal` | `packages/goal/command-goal/`,人类命令生产方 | 为 TUI 注册构建在目标领域之上的 `/goal` 状态、创建、编辑、暂停、恢复与清除。 | | `@deepseek-ai/dsh-tool-ralph` | `packages/workflow/tool-ralph/`,固定工作流消费者 | 注册 `ralph({ objective, maxRounds? })`,验证全新结构化 provider 与有界 `RalphRoundReport`,并返回 `complete`、`blocked` 或 `budget-limited`。 | -详细契约分别由[目标领域](2026-07-19-persisted-same-session-goal-domain.md)、[模型目标工具](2026-07-19-model-facing-goal-tools.md)、[目标回合驱动器](2026-07-19-same-session-goal-round-driver.md)、[命令注册表](2026-07-19-plugin-command-registration.md)、[人类目标命令](2026-07-19-human-goal-command.md)与 [Ralph 工作流工具](2026-07-19-fresh-agent-ralph-workflow-tool.md) Agent Note 拥有。 +详细契约分别由[目标领域](2026-07-19-persisted-same-session-goal-domain.md)、[Goal 自有事件](../architecture/2026-07-31-goal-owned-durable-events.md)、[模型目标工具](2026-07-19-model-facing-goal-tools.md)、[目标回合驱动器](2026-07-19-same-session-goal-round-driver.md)、[命令注册表](2026-07-19-plugin-command-registration.md)、[人类目标命令](2026-07-19-human-goal-command.md)与 [Ralph 工作流工具](2026-07-19-fresh-agent-ralph-workflow-tool.md) Agent Note 拥有。 ### 持久目标状态与实时权限 -一个会话至多有一个当前 goal。每次变更都通过 `Agent.inject()` 产生的持久 `agent/inbox/spliced` 插入项提交,并携带带版本的完整快照或带修订号的 clear 墓碑。其排队上下文只有在后续获准时才对模型可见,丢弃它不会回滚 goal。会话日志是唯一持久真源,因此普通持久化、恢复与 `SessionStore.fork()` 会携带 goal,无需第二个数据库或人为取消记录。 +一个会话至多有一个当前 goal。每次变更都通过持久 `goal/change` 事件提交,并携带带版本的完整快照或带修订号的 clear 墓碑;inbox 状态不参与其中。会话日志是唯一持久真源,因此普通持久化、恢复与 `SessionStore.fork()` 会携带 goal,无需第二个数据库或人为取消记录。 持久阶段只有 `active`、`paused`、`blocked` 与 `complete`。阻塞目标必须携带 `GoalBlockReason`,其中包含稳定的小写 kebab-case `code` 与非空的人类可读 `message`;用量限制、Round 耗尽、模型失败与策略拒绝都是原因代码,而不是额外生命周期阶段。独立激活态是 `armed` 或 `disarmed`,且永不持久化。创建与显式恢复会激活目标;停止转换、会话启动、fork 重放、驱动器替换和驱动器拆卸都会让目标保持未激活。 @@ -58,7 +58,7 @@ fork 会话会继承持久目标前缀,因为这是自然的重放结果。for ### 同会话续行 -Goal Round 驱动器为每个准确实时 agent 至多拥有一个待定预留。只有 goal 处于活跃且已激活状态、agent 空闲、不存在竞争性人类工作、待定变更插入项已经通过持久性检查点、准确 goal id/revision/Round 仍匹配,并且下游 pre-step 策略接受时,它才会接纳预留。其 `agent/pre-step` 围栏会在下游监听器前后检查这些事实,防止编辑、暂停、人类消息或卸载竞争接纳陈旧工作。 +Goal Round 驱动器为每个准确实时 agent 至多拥有一个待定预留。只有 goal 处于活跃且已激活状态、agent 空闲、不存在竞争性人类工作、最新变更已经通过持久性检查点、准确 goal id/revision/Round 仍匹配,并且下游 pre-step 策略接受时,它才会接纳预留。其 `agent/pre-step` 围栏会在下游监听器前后检查这些事实,防止编辑、暂停、人类消息或卸载竞争接纳陈旧工作。 只有已准入、Round 为正数且来源为 goal 的 `user/message` 会计入一个 Round。陈旧预留会在轮次打开前被拒绝,不会消耗上限。并发 goal revision 会胜过旧 Round 的结算。 @@ -122,7 +122,7 @@ Codex 提供了这里采用的最小可观察目标 UX:一个附着于聊天 - **聚合预算**——`maxGoalRounds` 与 Ralph `maxRounds` 是唯一聚合工作量限制。token、货币、耗时、provider 用量与逐 Round 价格准入策略均不存在。 - **没有持久自治运行器**——同会话目标事实会持久化,但激活与调度只存在于进程内,并且有意在恢复后等待人类输入。Ralph 位于前台,进程丢失后无法恢复。后台收集、重启恢复与无人值守常驻执行均予以延期。 - **没有时间调度器**——间隔 `/loop`、cron、主动维护以及云端或桌面调度不在本决策范围内。 -- **没有通用 loop 日志或执行世界回退**——会话重放会重建模型可见目标历史,而不会恢复此前文件、进程、环境、凭据或外部副作用。Ralph 把当前工作区作为权威,并且没有跨运行日志。 +- **没有通用 loop 日志或执行世界回退**——会话重放会重建目标历史,而不会恢复此前文件、进程、环境、凭据或外部副作用。Ralph 把当前工作区作为权威,并且没有跨运行日志。 - **没有目标反思器**——concern 事件、自动无进展启发式、由独立反思器执行的目标修订、卡住模式检测与 `loop_split` 均未实现。人类可以直接编辑、暂停、清除或恢复目标。 - **Ralph 策略仍然狭窄**——一个 Round 创建一个全新子 agent;Round 内扇出、评估器/工作者角色分离、动态 provider/模型选择与结构化递归 Ralph 工具禁止都需要独立策略表面。提示词指导不是强制执行。 - **Ralph 不会重试失败的子 agent**——普通失败会保留失败 Round 与上一份有效交接,而致命工作流基础设施错误可能在该状态可用前结束。重试次数、退避与更丰富的失败传输需要独立的策略与接缝设计。 diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml index 7a4d016339..b33e1b2020 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.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-human-goal-command.md -2026-07-19-human-goal-command.md: 1847985b49c9728df7068d866ee698a5ee8d27e6 -2026-07-19-human-goal-command.zh.md: 9a515a7b9d40b2849a2554118fb4f493c4fa5633 +2026-07-19-human-goal-command.md: 5fdd80f7423b80e84e58f7379130ee59a2e8a723 +2026-07-19-human-goal-command.zh.md: ef0b42370963aced9a20d5aae230c2a9da55def3 diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md index 1847985b49..5fdd80f742 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md @@ -34,7 +34,7 @@ Status output omits branded ids and compare-and-set revisions because those are Expected `GoalError` failures become one stable, branded-id-free `CommandResult.error`, so domain diagnostics do not leak compare-and-set internals into the human surface and invalid operations never enter model history. The current status supplies the actionable state-specific recovery. Other exceptions remain adapter-visible command failures; treating programmer faults as ordinary domain errors would hide defects. The command handler performs only synchronous domain mutations, so request cancellation is decided by the command registry before the mutation begins and there is no escaped asynchronous side effect to unwind. -Generic slash input, status text, and errors are not persisted. Successful goal mutations use the existing `Agent.inject()` path: the durable inbox insertion commits the raw goal snapshot or clear tombstone, while later admission independently makes that queued context model-visible. The command therefore changes no session format and introduces no second audit record that could disagree with the domain event. +Generic slash input, status text, and errors are not persisted. Successful goal mutations append the domain-owned `goal/change` event and do not queue model context. The command introduces no second audit record that could disagree with the domain event. ### App composition diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md index 9a515a7b9d..ef0b423709 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md @@ -34,7 +34,7 @@ Status: implemented 预期的 `GoalError` 失败会变为一个稳定且不含品牌化 id 的 `CommandResult.error`,使领域诊断不会向人类表面泄露比较并交换内部细节,非法操作也绝不会进入模型历史。当前状态负责提供针对具体状态且可执行的恢复路径。其他异常仍是适配器可见的命令失败;若把程序缺陷当成普通领域错误,就会隐藏问题。命令处理器只执行同步领域变更,因此请求取消会在变更开始前由命令注册表决定,不存在需要回滚的外逸异步副作用。 -通用斜杠输入、状态文本与错误不会持久化。成功的 goal 变更使用现有 `Agent.inject()` 路径:持久 inbox 插入项提交原始 goal 快照或 clear 墓碑,后续准入则单独使已排队的上下文对模型可见。因此该命令不会改变会话格式,也不会引入可能与领域事件不一致的第二份审计记录。 +通用斜杠输入、状态文本与错误不会持久化。成功的 goal 变更会追加领域自有的 `goal/change` 事件,而且不会把模型上下文排队。该命令不会引入可能与领域事件不一致的第二份审计记录。 ### 应用组合 diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml index 580930ef46..c45c140eb0 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.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-persisted-same-session-goal-domain.md -2026-07-19-persisted-same-session-goal-domain.md: 7b09676e023b909da8c5a54ae0efa901b1d201e8 -2026-07-19-persisted-same-session-goal-domain.zh.md: 1bb9aab9d810719d0d0cee3a15d1cc2bc8354db7 +2026-07-19-persisted-same-session-goal-domain.md: ce93a652f3910912fbf6afc28061de4d13eb2de2 +2026-07-19-persisted-same-session-goal-domain.zh.md: 15f406281bb284ddb5b54ceabc56db8810c65b77 diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md index 7b09676e02..ce93a652f3 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md @@ -18,11 +18,11 @@ The durable phases are `active`, `paused`, `blocked`, and `complete`. A blocked ### Durable record and replay -Every mutation uses `Agent.inject()` to insert one round-zero goal-sourced message containing a versioned full snapshot or, for clear, a revisioned tombstone. The mutation commits in the durable `agent/inbox/spliced` insertion even if its context remains queued and never reaches the model; later removal or discard does not roll it back. If the same message is admitted as `user/message`, replay verifies its id, source, metadata, and rendered `...` content against the insertion without applying the mutation again. This descriptive delimiter follows the repository's existing `` convention and [Anthropic's published guidance to structure mixed prompt content with consistent descriptive XML tags](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags). That is public model-experience prior art, not evidence about any provider's proprietary training corpus. The session log is the only durable source of truth, so persistence and fork inherit goal records without another database or header field. +Every mutation appends a versioned `goal/change` session event containing a full snapshot or, for clear, a revisioned tombstone. The session log is the only durable source of truth, so persistence and fork inherit goal records without another database or header field. The [goal-owned durable event decision](../architecture/2026-07-31-goal-owned-durable-events.md) owns the separation from inbox state and model context. -The replay fold derives mutations only from inbox insertions and validates JSON shape, source attribution, rendered content, message-id consistency on later admission, fresh ids, revision continuity, lifecycle transitions, counters, and monotonic per-goal timestamps. Goal rounds advance only from positive sequential admitted `user/message` source numbers for the current active revision and cannot exceed `maxGoalRounds`; ordinary session turns do not affect the counter. A malformed current-format record fails replay rather than being ignored or repaired. +The replay fold derives lifecycle mutations only from `goal/change` and validates JSON shape, fresh ids, revision continuity, lifecycle transitions, counters, and monotonic per-goal timestamps. Goal rounds advance only from positive sequential admitted `user/message` source numbers for the current active revision and cannot exceed `maxGoalRounds`; ordinary session turns do not affect the counter. A malformed current-format record fails replay rather than being ignored or repaired. -The service reconciles an injected mutation with its synchronous inbox insertion by message id, so reentrant insertion observers apply each accepted change exactly once and a later admission only checks consistency. Incremental replay advances its cursor after each valid event and remains positioned at the first corrupt event, so later reads report the same durable fault. The durable log remains authoritative after restart. +Incremental replay advances its cursor after each valid event and remains positioned at the first corrupt event, so later reads report the same durable fault. The durable log remains authoritative after restart. ### Lifecycle and live activation @@ -32,16 +32,16 @@ A cache built from any seed starts disarmed, and every `agent/session-start` edg ### Service boundary -The service accepts only the exact live `Agent` object registered under its id. Successful mutation injection emits the scoped `goal/changed` event with contained listener failures. Policy consumers use this service plus the public `Agent` interface and `agent/*` events; the goal domain does not import or modify `dsh-agent-loop`. +The service accepts only the exact live `Agent` object registered under its id. A committed mutation emits the scoped `goal/changed` event with contained listener failures. Policy consumers use this service plus the public `Agent` interface and `agent/*` events; the goal domain does not import or modify `dsh-agent-loop`. ## Testing -Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, blocker reason validation and retention, cap enforcement on resume, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start and lifecycle-owner disarming, active-goal rearming, durable insertion folding, reentrant insertion observation, admission consistency, discarded-context persistence, rejected-injection rollback, stable corrupt-event replay, service/listener disposal, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the goal record and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. +Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, blocker reason validation and retention, cap enforcement on resume, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start and lifecycle-owner disarming, active-goal rearming, durable event folding, inbox independence, stable corrupt-event replay, service/listener disposal, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the goal record and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. ## Alternatives considered - **Store goals in a separate database or session header** — rejected because the session log already supplies ordering, persistence, fork prefixes, and reconstructability; a second store introduces atomicity and lineage questions. -- **Use goal-only log events without queued context** — rejected because durable state that changes future model behavior must have a reconstructable path into model input. The inbox insertion supplies durable commit while retaining that model-facing path. +- **Couple each durable mutation to queued model context** — rejected by the later [goal-owned durable event decision](../architecture/2026-07-31-goal-owned-durable-events.md): goal tools and scheduled continuation prompts expose state when needed, while domain persistence remains independent from queue outcomes. - **Persist activation and restart automatically** — rejected because opening or resuming a session must wait for human input; durable phase records status, not fresh authority to spend resources. - **Count all session turns as goal rounds** — rejected because one session can contain human clarification, inspection, and unrelated work; only goal-attributed continuation turns consume this budget. - **Add goal state or a generic loop abstraction to `dsh-agent-loop`** — rejected because state and continuation policy can compose through existing plugins, `Agent` verbs, and events without privileging the shipped loop implementation. @@ -50,7 +50,7 @@ Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set r - Goal history survives persistence, resume, compaction of unrelated nodes, and session fork as ordinary session data. - Resume and fork expose the same durable phase while remaining operationally inert until an explicit resume mutation arms activation. -- Full snapshots simplify inspection and strict replay; when admitted, they repeat the objective and state fields in model history until compaction shadows them. +- Full snapshots simplify inspection, strict replay, and last-wins projection without adding mutation-only messages to model history. - Revision and lifecycle validation reject tampered, partially written, or producer-inconsistent goal records early. - Round caps bound continuation count only; policy consumers map round, token, currency, time, and provider limits to blocked reasons when they stop work. diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md index 1bb9aab9d8..15f406281b 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md @@ -18,11 +18,11 @@ Status: implemented ### 持久记录与回放 -每次变更都使用 `Agent.inject()` 插入一条 Round 为 0、来源为 goal 的消息,其中包含带版本的完整快照;clear 则包含带修订号的墓碑。变更会在持久 `agent/inbox/spliced` 插入项中提交,即使其上下文仍在队列中且从未抵达模型也是如此;后续移除或丢弃不会回滚变更。如果同一消息获准成为 `user/message`,回放会对照插入项验证其 id、来源、元数据和渲染后的 `...` 内容,而不会再次应用变更。这个描述性分隔符沿用了仓库已有的 `` 约定,也符合 [Anthropic 关于用一致且描述明确的 XML 标签组织混合提示词内容的公开指南](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags)。这是公开的模型体验先例,并非对任何提供方专有训练语料的推断。会话日志是唯一的持久真源,因此持久化和 fork 会继承 goal 记录,而无需另设数据库或头字段。 +每次变更都会追加带版本的 `goal/change` 会话事件,其中包含完整快照;clear 则包含带修订号的墓碑。会话日志是唯一的持久真源,因此持久化和 fork 会继承 goal 记录,而无需另设数据库或头字段。[Goal 自有持久事件决策](../architecture/2026-07-31-goal-owned-durable-events.md)负责 goal 状态与 inbox 状态、模型上下文之间的职责分离。 -回放折叠只从 inbox 插入项派生变更,并校验 JSON 形状、来源归属、渲染内容、后续准入时消息 id 的一致性、新 id、修订连续性、生命周期转换、计数器以及单个 goal 内单调递增的时间戳。只有当前活跃修订上带正数且连续编号、已准入的 `user/message` 来源才会推进 Goal Round,且不能超过 `maxGoalRounds`;普通会话轮次不会影响该计数器。当前格式的畸形记录会使回放失败,而不会被忽略或修复。 +回放折叠只从 `goal/change` 派生生命周期变更,并校验 JSON 形状、新 id、修订连续性、生命周期转换、计数器以及单个 goal 内单调递增的时间戳。只有当前活跃修订上带正数且连续编号、已准入的 `user/message` 来源才会推进 Goal Round,且不能超过 `maxGoalRounds`;普通会话轮次不会影响该计数器。当前格式的畸形记录会使回放失败,而不会被忽略或修复。 -服务按消息 id 对账注入的变更与其同步 inbox 插入项,因此重入插入观察器对每项已接受变更只应用一次,后续准入只校验一致性。增量回放会在每个有效事件后推进游标,并停留在首个损坏事件处,因此后续读取会报告同一个持久故障。重启后仍以持久日志为准。 +增量回放会在每个有效事件后推进游标,并停留在首个损坏事件处,因此后续读取会报告同一个持久故障。重启后仍以持久日志为准。 ### 生命周期与实时激活态 @@ -32,16 +32,16 @@ Status: implemented ### 服务边界 -服务只接受在对应 id 下注册的同一个实时 `Agent` 对象。成功注入变更后,它会发出带作用域的 `goal/changed` 事件,并隔离监听器失败。策略消费者通过本服务、公共 `Agent` 接口和 `agent/*` 事件工作;目标领域既不导入也不修改 `dsh-agent-loop`。 +服务只接受在对应 id 下注册的同一个实时 `Agent` 对象。变更提交后,它会发出带作用域的 `goal/changed` 事件,并隔离监听器失败。策略消费者通过本服务、公共 `Agent` 接口和 `agent/*` 事件工作;目标领域既不导入也不修改 `dsh-agent-loop`。 ## 测试 -单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、阻塞原因校验与保留、恢复时的上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动与生命周期所有者解除激活、活跃目标重新激活、持久插入折叠、重入插入观察、准入一致性、丢弃上下文后的持久性、注入拒绝回滚、损坏事件的稳定回放、服务与监听器销毁、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性,以及连续 Goal Round 归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费方,再从外部读取持久 JSONL,以验证 goal 记录以及不存在未经请求的 Goal Round。包源码受仓库逐文件 100% 覆盖率门禁约束。 +单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、阻塞原因校验与保留、恢复时的上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动与生命周期所有者解除激活、活跃目标重新激活、持久事件折叠、inbox 独立性、损坏事件的稳定回放、服务与监听器销毁、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性,以及连续 Goal Round 归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费方,再从外部读取持久 JSONL,以验证 goal 记录以及不存在未经请求的 Goal Round。包源码受仓库逐文件 100% 覆盖率门禁约束。 ## 考虑过的替代方案 - **把目标存入独立数据库或会话头**——不予采纳,因为会话日志已经提供顺序、持久化、fork 前缀与可重建性;第二份存储会引入原子性和谱系问题。 -- **使用不带排队上下文、仅供 goal 使用的日志事件**——不予采纳,因为会改变后续模型行为的持久状态必须拥有可重建的模型输入路径。inbox 插入在提供持久提交的同时保留了这条面向模型的路径。 +- **把每次持久变更与排队的模型上下文绑定。** 后续的 [Goal 自有持久事件决策](../architecture/2026-07-31-goal-owned-durable-events.md)不采用这一方案:goal 工具与已调度的继续执行提示词会在需要时暴露状态,而领域持久化不依赖队列结果。 - **持久化激活态并自动重启**——不予采纳,因为打开或恢复会话时必须等待人类输入;持久阶段记录状态,而不是再次消耗资源的授权。 - **把所有会话轮次都计为目标回合**——不予采纳,因为同一会话可以包含人类澄清、检查和无关工作;只有归属于目标的继续执行轮次才消耗该预算。 - **向 `dsh-agent-loop` 添加目标状态或通用循环抽象**——不予采纳,因为状态与继续执行策略可以通过现有插件、`Agent` 动词和事件组合,而无需赋予默认循环实现特权。 @@ -50,7 +50,7 @@ Status: implemented - 目标历史作为普通会话数据,在持久化、恢复、无关节点压缩和会话 fork 后继续保留。 - 恢复与 fork 会暴露同一持久阶段,但在显式恢复变更激活目标前不会执行任何操作。 -- 完整快照便于检查和严格回放;获准后,它们会在模型历史中重复目标描述与状态字段,直到压缩将其遮蔽。 +- 完整快照便于检查、严格回放与 last-wins 投影,且不会向模型历史添加只用于变更的消息。 - 修订号与生命周期校验会尽早拒绝遭篡改、部分写入或生产者不一致的目标记录。 - 回合上限只约束继续执行次数;当回合、token、费用、时间或提供方限制停止工作时,策略消费者会把它们映射为不同的阻塞原因。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index a73d6804be..ef38b3af12 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -444,13 +444,12 @@ Source: [`packages/fs/fs/src/index.ts:54`](../../packages/fs/fs/src/index.ts) ### `goal/changed` — emit -Goal mutation accepted by one live agent. The matching message has already committed through a durable inbox insertion; later admission or discard does not change that fact. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. +Goal mutation accepted by one live agent. The matching `goal/change` session event has already committed. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog /** - * Goal mutation accepted by one live agent. The matching message has - * already committed through a durable inbox insertion; later admission or - * discard does not change that fact. Listener failures are contained. + * Goal mutation accepted by one live agent. The matching `goal/change` + * session event has already committed. Listener failures are contained. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @param agent - agent whose session owns the goal. * @param change - fresh current projection or clear tombstone. diff --git a/docs/core-data-structures/goal.i18n.yaml b/docs/core-data-structures/goal.i18n.yaml index 85cd092263..ad5a49b735 100644 --- a/docs/core-data-structures/goal.i18n.yaml +++ b/docs/core-data-structures/goal.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/goal.md -goal.md: bb8b4525f7e12a904b1ba77cdefa4e4e1650ffe8 -goal.zh.md: c61bfcf7683b3aa835ee7cca58aba861af847d17 +goal.md: fc6a7e63e58fc7cd4bc524be1e66515593680d95 +goal.zh.md: e6350a4b6307f117361167a532d558d3be8cb367 diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md index bb8b4525f7..fc6a7e63e5 100644 --- a/docs/core-data-structures/goal.md +++ b/docs/core-data-structures/goal.md @@ -71,10 +71,10 @@ interface GoalView extends GoalSnapshot { ## Durable changes -Every mutation is a round-zero goal-sourced message whose metadata is either a complete snapshot or a clear tombstone. It commits when `agent.inject()` records that message in the `inserted` payload of a durable `agent/inbox/spliced` event. The strict fold and persisted projection derive mutations only from these insertions, so deleting the queued context does not roll back goal state. A later `user/message` with the same id verifies the source, metadata, and verbatim rendered content against the insertion without applying the mutation again. +Every mutation is a durable `goal/change` session event whose payload is either a complete post-mutation snapshot or a clear tombstone. The strict fold and persisted projection derive lifecycle state only from these events; inbox mutations do not affect goal state. ```ts type-equiv -/** Full-snapshot goal mutation committed by an injected inbox message. */ +/** Full-snapshot goal mutation committed by a durable `goal/change` event. */ interface GoalSnapshotChangeMeta { readonly kind: 'goal/change' readonly version: 1 @@ -97,18 +97,16 @@ interface GoalClearChangeMeta { } ``` -Goal state changes use round `0`. A continuation consumer attributes each admitted user-message turn with a positive, sequential round number and the current revision; only these admitted `user/message` events advance `roundsStarted`. Replay rejects gaps, stale revisions, stopped phases, and cap overflow. +A continuation consumer attributes each admitted user-message turn with a positive, sequential round number and the current revision; only these admitted `user/message` events advance `roundsStarted`. Replay rejects non-positive rounds, gaps, stale revisions, stopped phases, and cap overflow. ```ts type-equiv -/** Message attribution for durable goal state and continuation rounds. */ +/** Message attribution for admitted continuation rounds. */ interface GoalMessageSource { readonly kind: 'goal' readonly goalId: GoalId readonly revision: number - /** Zero for state changes; positive for admitted continuation rounds. */ + /** Positive admitted continuation round. */ readonly round: number - /** Complete durable mutation carried only by round-zero state-change messages. */ - readonly change?: GoalChangeMeta } ``` @@ -133,7 +131,7 @@ interface EditGoalRequest { ``` ```ts type-equiv -/** Live notification after one goal mutation commits through inbox insertion. */ +/** Live notification after one durable goal mutation commits. */ interface GoalChanged { readonly operation: GoalOperation readonly ref: GoalRef @@ -144,4 +142,4 @@ interface GoalChanged { ## Service behavior -[`GoalService`](../../packages/goal/goal/src/index.ts) resolves creation defaults, folds strict replay from durable inbox insertions, enforces exact-live-agent identity and compare-and-set mutations, reconciles later admission by message id, and emits contained `goal/changed` notifications. The package [README](../../packages/goal/goal/README.md) owns the callable and model-visible contract. +[`GoalService`](../../packages/goal/goal/src/index.ts) resolves creation defaults, folds strict replay from durable `goal/change` events, enforces exact-live-agent identity and compare-and-set mutations, and emits contained `goal/changed` notifications. The package [README](../../packages/goal/goal/README.md) owns the callable and model-visible contract. diff --git a/docs/core-data-structures/goal.zh.md b/docs/core-data-structures/goal.zh.md index c61bfcf768..e6350a4b63 100644 --- a/docs/core-data-structures/goal.zh.md +++ b/docs/core-data-structures/goal.zh.md @@ -71,10 +71,10 @@ interface GoalView extends GoalSnapshot { ## 持久变更 -每次变更都是 Round 编号为 0、来源为目标的消息,其元数据要么是完整快照,要么是清除墓碑。当 `agent.inject()` 将该消息记录到持久 `agent/inbox/spliced` 事件的 `inserted` 载荷时,变更即已提交。严格折叠与持久投影只从这些插入项派生变更,因此删除队列中的上下文不会回滚目标状态。随后具有相同 id 的 `user/message` 会对照插入项验证来源、元数据和逐字渲染内容,而不会再次应用变更。 +每次变更都是持久的 `goal/change` 会话事件,其载荷要么是变更后的完整快照,要么是清除墓碑。严格折叠与持久投影只从这些事件派生生命周期状态;inbox 变更不会影响 goal 状态。 ```ts type-equiv -/** Full-snapshot goal mutation committed by an injected inbox message. */ +/** Full-snapshot goal mutation committed by a durable `goal/change` event. */ interface GoalSnapshotChangeMeta { readonly kind: 'goal/change' readonly version: 1 @@ -97,18 +97,16 @@ interface GoalClearChangeMeta { } ``` -目标状态变更使用 Round `0`。续跑消费方会为每个获准的用户消息轮次标注正数且连续的 Round 编号和当前修订号;只有这些获准的 `user/message` 事件会推进 `roundsStarted`。回放会拒绝编号缺口、陈旧修订号、已停止阶段和超出上限。 +续跑消费方会为每个获准的用户消息轮次标注正数且连续的 Round 编号和当前修订号;只有这些获准的 `user/message` 事件会推进 `roundsStarted`。回放会拒绝非正数 Round、编号缺口、陈旧修订号、已停止阶段和超出上限。 ```ts type-equiv -/** Message attribution for durable goal state and continuation rounds. */ +/** Message attribution for admitted continuation rounds. */ interface GoalMessageSource { readonly kind: 'goal' readonly goalId: GoalId readonly revision: number - /** Zero for state changes; positive for admitted continuation rounds. */ + /** Positive admitted continuation round. */ readonly round: number - /** Complete durable mutation carried only by round-zero state-change messages. */ - readonly change?: GoalChangeMeta } ``` @@ -133,7 +131,7 @@ interface EditGoalRequest { ``` ```ts type-equiv -/** Live notification after one goal mutation commits through inbox insertion. */ +/** Live notification after one durable goal mutation commits. */ interface GoalChanged { readonly operation: GoalOperation readonly ref: GoalRef @@ -144,4 +142,4 @@ interface GoalChanged { ## 服务行为 -[`GoalService`](../../packages/goal/goal/src/index.ts) 解析创建默认值、从持久 inbox 插入项执行严格回放折叠、校验确切的活跃 agent 身份、以比较并设置方式执行变更、按消息 id 对账后续准入,并发出 `goal/changed` 通知;监听器故障会被隔离。包 [README](../../packages/goal/goal/README.md) 负责记录可调用契约和面向模型的契约。 +[`GoalService`](../../packages/goal/goal/src/index.ts) 解析创建默认值、从持久 `goal/change` 事件执行严格回放折叠、校验确切的活跃 agent 身份、以比较并设置方式执行变更,并发出 `goal/changed` 通知;监听器故障会被隔离。包 [README](../../packages/goal/goal/README.md) 负责记录可调用契约和面向模型的契约。 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index c94ccd8cc0..34dddccb30 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -150,7 +150,7 @@ Source: [`packages/ui/user-approval/src/index.ts:55`](../packages/ui/user-approv /** * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy - * from the prompt section and the narrator's notices). The LAST such + * from the cache-safe runtime-context snapshot). The LAST such * event is the session's override ({@link effectiveApprovalPolicy}). * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. @@ -230,20 +230,27 @@ Source: [`packages/ui/commands/src/index.ts:132`](../packages/ui/commands/src/in #### `compact/end` — log-only ```ts persistence-catalog -/** Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. */ -'compact/end': { turn: number; error?: string } +/** + * Marks the end of a compaction — log-only, releases the lock. Its owner + * matches `compact/start`; `error` records an unsuccessful attempt. + */ +'compact/end': { turn: number | null; error?: string } ``` -Source: [`packages/compact/compact/src/types.ts:44`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:51`](../packages/compact/compact/src/types.ts) #### `compact/start` — log-only ```ts persistence-catalog -/** Marks the start of a compaction — log-only, holds the lock until `compact/end`. */ -'compact/start': { turn: number } +/** + * Marks the start of a compaction — log-only, holds the lock until + * `compact/end`. A numbered owner is strictly enclosed by that open turn; + * `null` identifies a standalone manual transaction between turns. + */ +'compact/start': { turn: number | null } ``` -Source: [`packages/compact/compact/src/types.ts:15`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact/src/types.ts) #### `compact/summary` — log-only @@ -279,7 +286,20 @@ Source: [`packages/compact/compact/src/types.ts:15`](../packages/compact/compact Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:26`](../packages/compact/compact/src/types.ts) + +### `goal/*` + +#### `goal/change` — log-only + +```ts persistence-catalog +/** + * Complete post-mutation goal state or clear tombstone. + */ +'goal/change': GoalChangeMeta +``` + +Source: [`packages/goal/goal/src/domain.ts:81`](../packages/goal/goal/src/domain.ts) ### `hook/*` @@ -371,7 +391,7 @@ Source: [`packages/llm/llm-retry/src/index.ts:17`](../packages/llm/llm-retry/src 'permission/preset': { preset: string } ``` -Source: [`packages/ui/permission/src/index.ts:49`](../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:50`](../packages/ui/permission/src/index.ts) ### `plan/*` @@ -661,3 +681,14 @@ Source: [`packages/core/session/src/types.ts:174`](../packages/core/session/src/ ``` Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) + +### `web/*` + +#### `web/deepseek-search-llm-request` — log-only + +```ts persistence-catalog +/** Secret-free auxiliary DeepSeek search request recorded before dispatch. */ +'web/deepseek-search-llm-request': DeepSeekSearchLlmRequest +``` + +Source: [`packages/web/web-search-deepseek/src/provider.ts:83`](../packages/web/web-search-deepseek/src/provider.ts) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index d772522d6e..6ebc2c52c1 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -25,7 +25,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | -| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `agent/inbox/spliced goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | +| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `goal/change for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index abcf75cba4..3a856d0220 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -901,13 +901,9 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: if (!Object.hasOwn(values, 'title')) return [] return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }] } - // Goal fold: inserting a round-zero goal change durably advances the unit; - // later admission of the same message must not advance it again. - if (type === 'agent/inbox/spliced') { - const inserted = (event as unknown as { data: { inserted: UserMessage[] } }).data.inserted - if (inserted.some(message => goalChangeOf(message) !== undefined)) { - return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }] - } + // The goal domain's own durable change advances its projection. + if (type === 'goal/change') { + return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }] } // Standing-plan fold: writes replace the list; turn/start clears it (null). if (type === 'todo/write' || type === 'turn/start') { @@ -1140,7 +1136,7 @@ interface FxGoalProjection { updatedAt: number } -/** One durable goal change riding a round-zero goal-sourced inbox insertion. */ +/** One durable goal change. */ type FxGoalChange = | { kind: 'goal/change'; version: 1; operation: 'clear'; cleared: { id: string; revision: number }; clearedAt: number } | { @@ -1153,14 +1149,6 @@ type FxGoalChange = updatedAt: number } -/** Decode a fixture goal change from its durable inbox message. */ -function goalChangeOf(message: UserMessage): FxGoalChange | undefined { - const source = message.source as unknown as { kind?: string; round?: number; change?: FxGoalChange } - if (source.kind !== 'goal' || source.round !== 0) return undefined - const change = source.change - return change?.kind === 'goal/change' ? change : undefined -} - /** * Current goal projection over the full log (host parallel: the GoalService * unit's last-wins fold of goal/change whole values; clear returns null). @@ -1169,18 +1157,12 @@ function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null { for (let i = log.length - 1; i >= 0; i--) { const event = log[i] as unknown as { type: string - data?: { inserted?: UserMessage[] } + data?: FxGoalChange } | undefined - if (event === undefined || event.type !== 'agent/inbox/spliced') continue - const inserted = event.data?.inserted ?? [] - for (let j = inserted.length - 1; j >= 0; j--) { - const message = inserted[j] - if (message === undefined) continue - const change = goalChangeOf(message) - if (change === undefined) continue - if (change.operation === 'clear') return null - return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt } - } + if (event === undefined || event.type !== 'goal/change' || event.data === undefined) continue + const change = event.data + if (change.operation === 'clear') return null + return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt } } return null } @@ -1419,33 +1401,12 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { for (const frame of projectionFramesOf(id, log, event)) emitMux(frame) } - /** Append one goal/change as its round-zero goal-sourced inbox insertion (host GoalService parallel). */ + /** Append one durable goal/change (host GoalService parallel). */ const appendGoalChange = (id: SessionId, change: FxGoalChange): FxGoalProjection => { - const ref = change.operation === 'clear' ? change.cleared : change.goal - const payload = change.operation === 'clear' - ? { cleared: change.cleared, clearedAt: change.clearedAt } - : { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt } const log = logOf(id) - const pendingNextStep = log.reduce((count, event) => { - const inboxEvent = event as unknown as { - type: string - data: { target: string; removedCount?: number; inserted: UserMessage[] } - } - if (inboxEvent.type !== 'agent/inbox/spliced' || inboxEvent.data.target !== 'next-step') return count - return count - (inboxEvent.data.removedCount ?? 0) + inboxEvent.data.inserted.length - }, 0) append(id, { - type: 'agent/inbox/spliced', - data: { - target: 'next-step', - start: pendingNextStep, - inserted: [ - userMessage( - text(`${JSON.stringify(payload)}`), - { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change } as unknown as MessageSource, - ), - ], - }, + type: 'goal/change', + data: change, }) return backscanGoal(log) as FxGoalProjection } diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 795f4ec9d6..6af3336940 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -960,23 +960,12 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { const goalEvents = goalHistory.result.value.events.map(entry => entry.event as unknown as { type: string data: { - target?: string - start?: number + operation?: string source?: { kind?: string; round?: number } - inserted?: Array<{ source?: { kind?: string; round?: number; change?: { operation?: string } } }> } }) - const goalSplices = goalEvents.filter(event => event.type === 'agent/inbox/spliced' - && event.data.inserted?.some(message => message.source?.kind === 'goal' && message.source.round === 0) === true) - expect(goalSplices.map(event => ({ target: event.data.target, start: event.data.start }))).toEqual([ - { target: 'next-step', start: 0 }, - { target: 'next-step', start: 1 }, - { target: 'next-step', start: 2 }, - { target: 'next-step', start: 3 }, - { target: 'next-step', start: 4 }, - { target: 'next-step', start: 5 }, - ]) - expect(goalSplices.map(event => event.data.inserted?.[0]?.source?.change?.operation)) + const goalChanges = goalEvents.filter(event => event.type === 'goal/change') + expect(goalChanges.map(event => event.data.operation)) .toEqual(['create', 'edit', 'pause', 'resume', 'complete', 'clear']) expect(goalEvents.some(event => event.type === 'user/message' && event.data.source?.kind === 'goal' && event.data.source.round === 0)).toBe(false) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 61dfd228cc..d323aa1b25 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1328,7 +1328,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'goal/changed', mode: 'emit', signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped, agent: Agent, change: GoalChanged): void', - jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching message has\n * already committed through a durable inbox insertion; later admission or\n * discard does not change that fact. Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - agent whose session owns the goal.\n * @param change - fresh current projection or clear tombstone.\n * @mode emit\n */', + jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching `goal/change`\n * session event has already committed. Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - agent whose session owns the goal.\n * @param change - fresh current projection or clear tombstone.\n * @mode emit\n */', summary: 'Goal mutation accepted by one live agent.', }, { diff --git a/packages/goal/command-goal/README.i18n.yaml b/packages/goal/command-goal/README.i18n.yaml index 0178199f1b..aaf50c7b03 100644 --- a/packages/goal/command-goal/README.i18n.yaml +++ b/packages/goal/command-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/command-goal/README.md -README.md: 47f81a5ae303d3587c0af1a26407f0f1f0ba0d88 -README.zh.md: f5d22fa4889da8b7a1e2ac73e78ebe76714b8d42 +README.md: 485a41abb22297f8018dceba258ec32692a28500 +README.zh.md: 819f635b5c7c7f94109c1f076e7ed84b22ad96dc diff --git a/packages/goal/command-goal/README.md b/packages/goal/command-goal/README.md index 47f81a5ae3..485a41abb2 100644 --- a/packages/goal/command-goal/README.md +++ b/packages/goal/command-goal/README.md @@ -17,7 +17,7 @@ Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin r Control words are case-insensitive only when they occupy the complete input. Every other non-empty suffix is an objective, so `/goal pause after verification` creates that literal objective. The goal domain trims and validates objectives. Because the generic command plane has no modal editor or confirmation primitive, `edit` takes its replacement inline and an unfinished replacement returns a direct error instructing the user to edit or clear. -Expected domain rejections become stable direct command errors without exposing branded ids or revisions. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; `dsh-goal` persists every accepted mutation through a durable inbox insertion and independently queues its model-facing context. +Expected domain rejections become stable direct command errors without exposing branded ids or revisions. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; `dsh-goal` persists every accepted mutation through its own durable `goal/change` event. ## Composition @@ -40,15 +40,15 @@ The TUI app enables the complete persisted-goal stack and this command by defaul #### What the model sees -The slash input and direct status/error output are absent from model requests. An accepted mutation queues the goal domain's raw `` snapshot or clear tombstone; the model sees it only if a later pre-step admits that context. The mutation remains durable if the queued message is discarded, and presentation text is never logged. +The slash input, mutation, and direct status/error output are absent from model requests. The goal domain records the mutation as `goal/change`; an enabled same-session driver may expose the resulting state in a later continuation prompt. Presentation text is never logged. #### Token effect -Reading status or receiving a direct command error adds no model tokens. An admitted mutation context adds the goal domain's retained full snapshot, while one discarded before admission adds none; an enabled same-session driver may add later goal-round prompts. +Reading status, mutating a goal, or receiving a direct command error adds no model tokens. An enabled same-session driver may add later goal-round prompts. #### KV Cache effect -Command discovery and direct output do not affect the cache. An admitted mutation context appends after the reusable history prefix; later compaction may replace the derived-history suffix. +Command discovery, mutations, and direct output do not affect the cache. Later continuation prompts follow the driver's ordinary request history. ## Known Limitations and Deferred Work diff --git a/packages/goal/command-goal/README.zh.md b/packages/goal/command-goal/README.zh.md index f5d22fa488..819f635b5c 100644 --- a/packages/goal/command-goal/README.zh.md +++ b/packages/goal/command-goal/README.zh.md @@ -17,7 +17,7 @@ 只有控制词占据完整输入时才不区分大小写。其他任何非空后缀都属于目标,因此 `/goal pause after verification` 会创建该字面目标。goal 领域会去除目标首尾空白并进行验证。由于通用命令平面没有模态编辑器或确认原语,`edit` 会内联接收替换内容;若试图替换未完成的 goal,则直接返回错误,提示用户执行 edit 或 clear。 -可预期的领域拒绝会变成稳定的直接命令错误,不公开带品牌类型的 id 或 revision。意外实现失败仍会 reject 分发,使适配器能将其报告为命令失败。通用命令文本和输出仍属于实时 UI 状态;`dsh-goal` 通过持久 inbox 插入项持久化每项已接受变更,并单独将其面向模型的上下文排队。 +可预期的领域拒绝会变成稳定的直接命令错误,不公开带品牌类型的 id 或 revision。意外实现失败仍会 reject 分发,使适配器能将其报告为命令失败。通用命令文本和输出仍属于实时 UI 状态;`dsh-goal` 通过自有的持久 `goal/change` 事件记录每项已接受变更。 ## 组合 @@ -40,15 +40,15 @@ TUI 应用默认启用完整的持久 goal 栈和此命令。ACP(Agent Client #### 模型看到的内容 -斜杠输入与直接状态/错误输出不会进入模型请求。已接受的变更会将 goal 领域的原始 `` 快照或 clear tombstone 排队;只有后续 pre-step 准入该上下文时,模型才会看到它。如果已排队的消息被丢弃,变更仍然持久;呈现文本绝不会记录到日志中。 +斜杠输入、变更以及直接状态/错误输出不会进入模型请求。Goal 领域把变更记录为 `goal/change`;已启用的同会话驱动器可以在后续继续执行提示词中暴露结果状态。呈现文本绝不会记录到日志中。 #### Token 影响 -读取状态或收到直接命令错误不会增加模型 token。获准的变更上下文会增加 goal 领域保留的完整快照,准入前被丢弃的上下文则不会增加;已启用的同会话驱动器还可能增加后续 Goal Round 提示词。 +读取状态、变更 goal 或收到直接命令错误不会增加模型 token。已启用的同会话驱动器可能增加后续 Goal Round 提示词。 #### KV Cache 影响 -命令发现与直接输出不会影响缓存。获准的变更上下文会追加到可复用历史前缀之后;后续压缩可能替换派生历史后缀。 +命令发现、变更与直接输出不会影响缓存。后续继续执行提示词遵循驱动器的普通请求历史。 ## 已知限制与暂缓事项 diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 944917366b..0d72d092f9 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -126,7 +126,7 @@ describe('/goal human command', () => { expect(created.text).toContain('Rounds: 0/256') expect(created.text).toContain('Activation: armed') expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release') - expect(domainEvents(test.session).map(event => event.type)).toEqual(['agent/inbox/spliced']) + expect(domainEvents(test.session).map(event => event.type)).toEqual(['goal/change']) const count = domainEvents(test.session).length await expect(run(test, ' replacement')).resolves.toEqual({ diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index ff241e2ae1..90ba77aab7 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -295,8 +295,8 @@ describe('same-session goal driving', () => { expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' }) expect(test.adapter.requests).toHaveLength(0) - // No admitted continuation round (positive round); goal state changes - // (round zero) are expected in the log. + // No admitted continuation round reached the model; goal state changes are + // represented by their own durable event. expect(test.agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0)).toBe(false) }) diff --git a/packages/goal/goal-session/tests/invariant.spec.ts b/packages/goal/goal-session/tests/invariant.spec.ts index b787c4763e..6baaa724da 100644 --- a/packages/goal/goal-session/tests/invariant.spec.ts +++ b/packages/goal/goal-session/tests/invariant.spec.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { GoalId, - renderGoalChange, type GoalSnapshotChangeMeta, type GoalView, } from '@deepseek-ai/dsh-goal' @@ -28,29 +27,12 @@ const change: GoalSnapshotChangeMeta = { updatedAt: 1, } -const changeSource = { - kind: 'goal', - goalId: change.goal.id, - revision: change.goal.revision, - round: 0, - change, -} as const - function view(roundsStarted: number): GoalView { return { ...change.goal, roundsStarted, createdAt: 1, updatedAt: 1, activation: 'armed' } } function appendChange(session: Session): void { - const message = createUserMessage({ - content: renderGoalChange(change), - source: changeSource, - }) - session.append('agent/inbox/spliced', { - target: 'next-step', start: 0, inserted: [message], - }) - session.append('turn/start', { turn: 1 }) - session.append('user/message', message, { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('goal/change', change) } function appendRound(session: Session, turn: number, content = renderGoalRoundPrompt(view(turn - 2), turn - 1)): void { @@ -93,7 +75,9 @@ describe('goal-session prompt invariants', () => { }), { surfaceOp: 'append' }) session.append('turn/end', { turn: 4, reason: { kind: 'completed' } }) - const stateSource = { ...changeSource, round: 0 } as const + const stateSource = { + kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0, + } as never session.append('turn/start', { turn: 5 }) expect(() => { session.append('user/message', createUserMessage({ @@ -132,12 +116,7 @@ describe('goal-session prompt invariants', () => { it('attributes an invalid durable prefix during late loading', async () => { const { ctx, session } = await mount(true) - session.append('turn/start', { turn: 1 }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'counterfeit goal state' }], - source: changeSource, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('goal/change', { ...change, extra: true } as never) appendRound(session, 2) await ctx.plugin(InvariantService, { enabled: true }) diff --git a/packages/goal/goal/README.i18n.yaml b/packages/goal/goal/README.i18n.yaml index 5657380084..f74e8c6bb6 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: f72efe2306f11dfa5f30ac927bb1b900f691a7be -README.zh.md: 4ca86a6c1aea228e069fbf25b22ec248297fae03 +README.md: caf01b3d2a088281749a73b78b839d60ac041316 +README.zh.md: de92b8b9d7757f80511fe128644738ebe2458af1 diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index f72efe2306..caf01b3d2a 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -21,13 +21,13 @@ 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 mutation passes a complete versioned snapshot through `agent.inject()`; clear uses a revisioned tombstone. The mutation commits when injection records the message in the durable `agent/inbox/spliced` insertion, even if that context remains queued and never reaches the model. Removing or discarding the queued message does not roll back the mutation. If the same message is later admitted as a model-visible `user/message`, replay verifies that its id, content, and typed `{ kind: 'goal', change }` source agree with the insertion without applying the mutation again. +Every mutation appends a durable `goal/change` event carrying the complete post-mutation snapshot; clear uses a revisioned tombstone. Goal state therefore does not depend on inbox placement, claim, admission, or discard. The session log is the only durable authority. -Strict replay derives mutations only from inbox insertions and rejects malformed shapes, reused message ids with different changes, source/content drift on admission, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential admitted goal rounds. Positive rounds advance only on admitted `user/message` events. Mutation timestamps clamp against the preceding goal update when wall time moves backward. Reentrant insertion observers see each accepted mutation exactly once, incremental replay retains its cursor at the first corrupt event, and `goal/changed` fires after injection succeeds with listener failures contained. +Strict replay derives lifecycle mutations only from `goal/change` and rejects malformed shapes, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential admitted goal rounds. Positive rounds advance only on admitted goal-sourced `user/message` events. Mutation timestamps clamp against the preceding goal update when wall time moves backward. Incremental replay retains its cursor at the first corrupt event, and `goal/changed` fires after the durable event commits with listener failures contained. Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. A continuation driver also calls `disarm()` before unload or after durability uncertainty. Session resume, fork, and driver replacement therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation. -The separately published `./invariant` companion maintains an independent fold of each attached session. It rejects malformed goal source changes, duplicate-id drift between insertion and admission, model-visible content drift, discontinuous revisions, illegal lifecycle transitions, timestamp regressions, and non-sequential admitted rounds before the candidate event enters the durable log. +The separately published `./invariant` companion maintains an independent fold of each attached session. It rejects malformed goal changes, discontinuous revisions, illegal lifecycle transitions, timestamp regressions, and non-sequential admitted rounds before the candidate event enters the durable log. ## Extension points @@ -39,15 +39,15 @@ Policy plugins call the service verbs and react to the scoped `goal/changed` eve #### What the model sees -Each mutation queues one raw user-role context block. If admitted, a snapshot is rendered as `{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}`; a clear renders the tombstone id/revision and `clearedAt`. The mutation remains durable if the queued context is discarded before admission, and there is no hidden state summary outside the session log. The descriptive XML delimiter follows this repository's existing `` convention and [Anthropic's published XML-tag prompting guidance](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags); it is public model-experience prior art, not a claim about any provider's proprietary training corpus. +Goal mutations do not inject model context. Goal tools return the current state, and a continuation consumer may render the objective and round state when it schedules model work. A future always-visible goal context belongs in a separate context plugin rather than the persistence path. #### Token effect -An admitted mutation adds one full snapshot to derived history until compaction shadows it; an insertion discarded before admission costs no model tokens. Full snapshots make each admitted record independently inspectable but repeat the objective and lifecycle fields. +Goal mutation events add no model tokens by themselves. Tool results and scheduled continuation prompts account for their own visible state. #### KV Cache effect -Append-only within an epoch after admission: each visible mutation follows the reusable request prefix and preceding history. Compaction may replace the derived-history suffix and move the reusable boundary. +There is no KV-cache effect until another component exposes goal state in model-visible input. ## Known Limitations and Deferred Work @@ -55,4 +55,4 @@ Append-only within an epoch after admission: each visible mutation follows the r - **Round-count budget only** — `maxGoalRounds` does not meter tokens, currency, wall time, or provider quotas. - **No independent evaluator** — the caller that records completion or blocking is authoritative; evaluator-backed certification is deferred to a separate policy layer. - **One current goal** — parallel objectives and a separate goal database are intentionally absent; history remains available in the session log after replacement or clear. -- **Trusted in-process producers** — a plugin with direct `Session` access can append counterfeit goal source data. Strict replay detects malformed or inconsistent records and leaves goal access failed at that record until the log is repaired; this is integrity detection, not plugin isolation. +- **Trusted in-process producers** — a plugin with direct `Session` access can append counterfeit `goal/change` data. Strict replay detects malformed or inconsistent records and leaves goal access failed at that record until the log is repaired; this is integrity detection, not plugin isolation. diff --git a/packages/goal/goal/README.zh.md b/packages/goal/goal/README.zh.md index 4ca86a6c1a..de92b8b9d7 100644 --- a/packages/goal/goal/README.zh.md +++ b/packages/goal/goal/README.zh.md @@ -21,13 +21,13 @@ 最多只有一个当前目标。创建操作会生成 revision 为 1、phase 为 active 的目标并启用续行。未完成的目标必须编辑、转换或清除;已完成目标可以由拥有全局未使用过的 id 的目标替换。编辑会保留 phase、blocker reason 与 activation。暂停、完成、阻塞和清除都会停用续行。阻塞会记录策略自有的 lower-kebab-case 代码和规范化的自由文本说明;提供方限制、配置预算、执行错误与请求人工输入都使用这一种持久 phase,不会扩增生命周期状态。只有配置的 Round 上限仍有剩余容量时,resume 才接受已停止 phase 或 phase 为 active 但已停用续行的目标;它会清除原 blocker reason。phase 为 active 且已启用续行的目标会拒绝冗余操作。 -每次变更都会通过 `agent.inject()` 传递完整的版本化快照;clear 使用带 revision 的 tombstone。注入将消息记录到持久 `agent/inbox/spliced` 插入项时,变更即已提交,即使该上下文仍在队列中且从未抵达模型也是如此。移除或丢弃已排队的消息不会回滚变更。如果同一消息随后获准成为模型可见的 `user/message`,回放会验证其 id、内容和带类型的 `{ kind: 'goal', change }` 来源与插入项一致,而不会再次应用变更。 +每次变更都会追加持久的 `goal/change` 事件,其中携带变更后的完整快照;clear 使用带 revision 的 tombstone。因此,goal 状态不依赖 inbox 放置、领取、准入或丢弃。会话日志是唯一的持久权威。 -严格回放只从 inbox 插入项派生变更,并拒绝形状错误、以不同变更复用消息 id、准入时的来源/内容漂移、不连续 revision、非法生命周期转换、每目标时间戳非单调,以及不连续的已准入 Goal Round。只有获准的 `user/message` 事件会推进正数 Round。挂钟时间倒退时,变更时间戳会限制在不早于上一次目标更新的值。可重入插入观察者会且只会看到每项已接受变更一次;增量回放会把游标保留在第一个损坏事件处;`goal/changed` 会在注入成功后触发,监听器失败会被隔离处理。 +严格回放只从 `goal/change` 派生生命周期变更,并拒绝形状错误、不连续 revision、非法生命周期转换、每目标时间戳非单调,以及不连续的已准入 Goal Round。只有来源为 goal 且已准入的 `user/message` 事件会推进正数 Round。挂钟时间倒退时,变更时间戳会限制在不早于上一次目标更新的值。增量回放会把游标保留在第一个损坏事件处;`goal/changed` 会在持久事件提交后触发,监听器失败会被隔离处理。 续行启用状态绝不持久化。新缓存与每次触发 `agent/session-start` 时都会停用续行,即使回放找到了持久 phase 为 active 的目标。续行驱动器在卸载前或持久性不确定后也会调用 `disarm()`。因此,会话恢复、fork 与驱动器替换会保留目标、phase、revision 和已准入 Round 数量,却不会启动工作;之后必须通过显式 resume 变更重新启用续行。 -单独发布的 `./invariant` 配套模块会为每个已挂接会话维护独立折叠。它会在候选事件进入持久日志前拒绝格式错误的 goal 来源变更、相同 id 在插入与准入之间的变更漂移、模型可见内容漂移、不连续 revision、非法生命周期转换、时间戳回退,以及不连续的已准入 Round。 +单独发布的 `./invariant` 配套模块会为每个已挂接会话维护独立折叠。它会在候选事件进入持久日志前拒绝格式错误的 goal 变更、不连续 revision、非法生命周期转换、时间戳回退,以及不连续的已准入 Round。 ## 扩展点 @@ -39,15 +39,15 @@ #### 模型看到的内容 -每项变更都会将一个原始用户角色上下文块排队。获准后,快照渲染为 `{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}`;clear 会渲染 tombstone id/revision 与 `clearedAt`。如果排队的上下文在准入前被丢弃,变更仍然持久;会话日志外不存在隐藏状态摘要。这种描述性 XML 分隔符遵循仓库已有的 `` 约定和 [Anthropic 发布的 XML 标签提示词指南](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags);它是公开的模型体验先例,并非关于任何提供方专有训练语料的声明。 +Goal 变更不会注入模型上下文。Goal 工具返回当前状态;继续执行消费方可以在调度模型工作时渲染目标描述与 Round 状态。未来如果需要始终可见的 goal 上下文,应由独立上下文插件实现,而不是放在持久化路径中。 #### Token 影响 -获准的变更会向派生历史增加一份完整快照,直到压缩(compaction)将其遮蔽;准入前被丢弃的插入项不消耗模型 token。完整快照让每条获准记录都能独立检查,但会重复目标和生命周期字段。 +Goal 变更事件本身不增加模型 token。工具结果与已调度的继续执行提示词分别计入其自身暴露的状态。 #### KV Cache 影响 -准入后在一个 epoch 内仅追加:每项可见变更都位于可复用请求前缀和既有历史之后。压缩可能替换派生历史后缀,并移动可复用边界。 +在其他组件把 goal 状态暴露为模型可见输入之前,不会影响 KV Cache。 ## 已知限制与暂缓事项 @@ -55,4 +55,4 @@ - **只有 Round 数量预算**:`maxGoalRounds` 不计量 token、货币、挂钟时间或提供方配额。 - **没有独立评估器**:记录完成或阻塞的调用方拥有最终决定权;由评估器支持的认证暂缓到独立策略层。 - **只有一个当前目标**:系统有意不支持并行目标或独立目标数据库;替换或清除后,历史仍可在会话日志中读取。 -- **信任进程内生产方**:能直接访问 `Session` 的插件可以追加伪造的 goal 来源数据。严格回放会检测格式错误或不一致的记录,并使 goal 访问从该记录起失败,直到日志修复;这是完整性检测,不是插件隔离。 +- **信任进程内生产方**:能直接访问 `Session` 的插件可以追加伪造的 `goal/change` 数据。严格回放会检测格式错误或不一致的记录,并使 goal 访问从该记录起失败,直到日志修复;这是完整性检测,不是插件隔离。 diff --git a/packages/goal/goal/src/domain.ts b/packages/goal/goal/src/domain.ts index bece1aa965..377ba402e2 100644 --- a/packages/goal/goal/src/domain.ts +++ b/packages/goal/goal/src/domain.ts @@ -35,7 +35,7 @@ export type GoalOperation = | 'block' | 'clear' -/** Full-snapshot goal mutation committed by an injected inbox message. */ +/** Full-snapshot goal mutation committed by a durable `goal/change` event. */ export interface GoalSnapshotChangeMeta { readonly kind: 'goal/change' readonly version: 1 @@ -55,18 +55,16 @@ export interface GoalClearChangeMeta { readonly clearedAt: number } -/** Durable change union carried by a goal-owned round-zero message source. */ +/** Durable change union carried by the goal domain's own session event. */ export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta -/** Message attribution for durable goal state and continuation rounds. */ +/** Message attribution for admitted continuation rounds. */ export interface GoalMessageSource { readonly kind: 'goal' readonly goalId: GoalId readonly revision: number - /** Zero for state changes; positive for admitted continuation rounds. */ + /** Positive admitted continuation round. */ readonly round: number - /** Complete durable mutation carried only by round-zero state-change messages. */ - readonly change?: GoalChangeMeta } declare module '@deepseek-ai/dsh-llm' { @@ -75,6 +73,15 @@ declare module '@deepseek-ai/dsh-llm' { } } +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * Complete post-mutation goal state or clear tombstone. + */ + 'goal/change': GoalChangeMeta + } +} + /** Pure replay fold of durable goal facts. */ export interface FoldedGoal { /** Current goal, absent after a clear or before the first create. */ @@ -101,7 +108,7 @@ export interface EditGoalRequest { readonly maxGoalRounds?: number } -/** Live notification after one goal mutation commits through inbox insertion. */ +/** Live notification after one durable goal mutation commits. */ export interface GoalChanged { readonly operation: GoalOperation readonly ref: GoalRef @@ -124,9 +131,8 @@ export type GoalErrorCode = declare module 'cordis' { interface Events { /** - * Goal mutation accepted by one live agent. The matching message has - * already committed through a durable inbox insertion; later admission or - * discard does not change that fact. Listener failures are contained. + * Goal mutation accepted by one live agent. The matching `goal/change` + * session event has already committed. Listener failures are contained. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @param agent - agent whose session owns the goal. * @param change - fresh current projection or clear tombstone. diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts index 5c42929cdc..5660d8d6d6 100644 --- a/packages/goal/goal/src/fold.ts +++ b/packages/goal/goal/src/fold.ts @@ -2,8 +2,6 @@ import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import type { UserMessage } from '@deepseek-ai/dsh-session' -import { renderGoalChange } from './render.ts' import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts' import type { GoalBlockReason, GoalPhase, GoalRef, GoalSnapshot } from './types.ts' import type { @@ -33,7 +31,6 @@ export interface GoalFoldState { updatedAt: number | undefined lastRef: GoalRef | undefined seenGoalIds: Set - insertedChangeMessages: Map } /** @@ -48,7 +45,6 @@ export function emptyGoalFoldState(): GoalFoldState { updatedAt: undefined, lastRef: undefined, seenGoalIds: new Set(), - insertedChangeMessages: new Map(), } } @@ -180,7 +176,7 @@ function goalSource(source: MessageSource): GoalMessageSource | undefined { if (source.kind !== 'goal') return undefined if (typeof source.goalId !== 'string' || source.goalId.length === 0 || !Number.isSafeInteger(source.revision) || source.revision < 1 - || !Number.isSafeInteger(source.round) || source.round < 0) { + || !Number.isSafeInteger(source.round) || source.round < 1) { throw new Error('goal message source is invalid') } return source @@ -307,81 +303,22 @@ export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): v state.lastRef = ref } -/** - * Decode and verify one goal state message without folding it. A goal state - * message has a round-zero goal source carrying the complete change; any other - * message returns `undefined`. Attribution and rendered-body drift fail loudly. - * @param message - inserted or admitted message to decode. - * @param location - event location included in replay failures. - * @returns validated change, or `undefined` when the message is not a goal state change. - */ -function decodeGoalMessage(message: UserMessage, location: string): GoalChangeMeta | undefined { - const source = goalSource(message.source) - if (source === undefined) { - const [block] = message.content - if (block?.type === 'text' && block.text.startsWith('')) { - throw new Error(`goal change at ${location} has mismatched source attribution`) - } - return undefined - } - if (source.round !== 0) return undefined - const change = decodeGoalChange(source.change) - if (change === undefined) throw new Error(`goal change at ${location} lacks source change data`) - const ref = goalChangeRef(change) - if (source.goalId !== ref.id || source.revision !== ref.revision) { - throw new Error(`goal change at ${location} has mismatched source attribution`) - } - if (JSON.stringify(message.content) !== JSON.stringify(renderGoalChange(change))) { - throw new Error(`goal change at ${location} has mismatched model-visible content`) - } - return change -} - /** * Apply one session event to the strict durable goal fold. * @param state - mutable fold accumulator. * @param event - next event in sequence order. */ export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): void { - if (event.type === 'agent/inbox/spliced') { - for (const message of event.data.inserted) { - const location = `session event ${event.seq}` - const change = decodeGoalMessage(message, location) - if (change === undefined) continue - const inserted = state.insertedChangeMessages.get(message.id) - if (inserted !== undefined) { - if (JSON.stringify(inserted) !== JSON.stringify(change)) { - throw new Error(`goal change at ${location} reuses a message id with different change data`) - } - continue - } - applyGoalChange(state, change) - state.insertedChangeMessages.set(message.id, change) - } + if (event.type === 'goal/change') { + const change = decodeGoalChange(event.data) + /* v8 ignore next -- the event's declared payload always identifies itself as a goal change. */ + if (change === undefined) throw new Error(`goal change at session event ${event.seq} has an invalid kind`) + applyGoalChange(state, change) return } if (event.type === 'user/message') { - const inserted = state.insertedChangeMessages.get(event.data.id) - const change = decodeGoalMessage(event.data, `session event ${event.seq}`) - if (inserted !== undefined && change !== undefined) { - if (JSON.stringify(inserted) !== JSON.stringify(change)) { - throw new Error(`goal change at session event ${event.seq} differs from its inbox insertion`) - } - return - } - if (change !== undefined) { - throw new Error(`goal change at session event ${event.seq} was not committed by an inbox insertion`) - } const source = goalSource(event.data.source) if (source === undefined) return - // A goal-sourced message without a change must be a positive-round - // admitted continuation prompt; round zero owes a durable source change. - /* v8 ignore next 3 -- decodeGoalMessage returns the change or fails loud for every - round-zero goal source, so only positive rounds reach here; the guard keeps - replay fail-loud against a decoder change */ - if (source.round === 0) { - throw new Error(`goal source at session event ${event.seq} lacks goal change data`) - } const current = state.goal if (current === undefined || current.phase !== 'active' || source.goalId !== current.id || source.revision !== current.revision || source.round !== state.roundsStarted + 1 diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 38eef5ba7d..f6a4a99fc6 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -11,17 +11,16 @@ import { z as zod } from 'zod' import type { ZodType } from 'zod' import { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' // Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' import { applyGoalEvent, + decodeGoalChange, emptyGoalFoldState, goalChangeRef, } from './fold.ts' import type { GoalFoldState } from './fold.ts' -import { renderGoalChange } from './render.ts' import { GOAL_CHANGE_VERSION, GoalError, @@ -54,7 +53,6 @@ export type * from './types.ts' export type * from './domain.ts' export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts' export { decodeGoalChange, foldGoal, goalChangeRef } from './fold.ts' -export { renderGoalChange } from './render.ts' declare module 'cordis' { interface Context { @@ -80,12 +78,6 @@ const goalProjectionSchema: ZodType = zod.union([ zod.null(), ]) as ZodType -/** Plain-JSON projection accumulator retaining duplicate-change identity. */ -type GoalProjectionState = readonly [ - value: GoalProjection | null, - insertedChangeMessageIds: readonly string[], -] - /** * Light last-wins fold of the `goal` projection unit. Unlike the strict * replay fold (fold.ts: transition validation, fail-loud on malformed @@ -99,31 +91,23 @@ type GoalProjectionState = readonly [ * @param event - the next committed session event. * @returns the next projection (same reference when the event is not a goal change). */ -export function applyGoalProjection(state: GoalProjectionState, event: SessionEvent): GoalProjectionState { - if (event.type !== 'agent/inbox/spliced') return state - let projection = state[0] - let insertedChangeMessageIds: string[] | undefined - const seen = new Set(state[1]) - for (const message of event.data.inserted) { - const source = message.source - const change = source.kind === 'goal' && source.round === 0 ? source.change : undefined - // oxlint-disable-next-line typescript/no-unnecessary-condition -- durable-boundary guard - if (seen.has(message.id) || change === undefined || change.kind !== 'goal/change') continue - seen.add(message.id) - insertedChangeMessageIds ??= [...state[1]] - insertedChangeMessageIds.push(message.id) - projection = change.operation === 'clear' - ? null - : { - goal: change.goal, - roundsStarted: change.roundsStarted, - createdAt: change.createdAt, - updatedAt: change.updatedAt, - } +export function applyGoalProjection(state: GoalProjection | null, event: SessionEvent): GoalProjection | null { + if (event.type !== 'goal/change') return state + let change: GoalChangeMeta | undefined + try { + change = decodeGoalChange(event.data) + } catch (_invalidPersistedGoalChange) { + return state } - return insertedChangeMessageIds === undefined - ? state - : [projection, insertedChangeMessageIds] + if (change === undefined) return state + return change.operation === 'clear' + ? null + : { + goal: change.goal, + roundsStarted: change.roundsStarted, + createdAt: change.createdAt, + updatedAt: change.updatedAt, + } } /** Deployment defaults for goal creation. */ @@ -138,12 +122,12 @@ export interface ResolvedConfig { defaultMaxGoalRounds: number } -/** Process-local cache plus activation intent crossing the synchronous injection boundary. */ +/** Process-local cache plus activation intent crossing the synchronous append boundary. */ interface GoalCache { readonly state: GoalFoldState activation: GoalActivation observedSeq: number - readonly pendingActivations: Map + pendingActivation: { readonly seq: number; readonly activation: GoalActivation } | undefined } /** Validated create input with every deployment default materialized. */ @@ -216,13 +200,13 @@ export class GoalService extends Service { // (see applyGoalProjection). The unit child activates only when a // projection registry is composed (headless assemblies stay unaffected). ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register<'goal', GoalProjectionState>({ + projectionCtx.sessionProjections.register<'goal', GoalProjection | null>({ key: 'goal', schema: goalProjectionSchema, - init: () => [null, []], + init: () => null, apply: applyGoalProjection, - view: state => state[0], - stateVersion: 3, + view: state => state, + stateVersion: 4, }) }) } @@ -436,7 +420,7 @@ export class GoalService extends Service { state, activation: 'disarmed', observedSeq: session.seq, - pendingActivations: new Map(), + pendingActivation: undefined, } this.caches.set(session, cache) return cache @@ -445,14 +429,11 @@ export class GoalService extends Service { /** Incrementally observe durable events and reconcile local activation intent. */ private sync(session: Session, cache: GoalCache): void { for (const event of session.events.slice(cache.observedSeq)) { - const newGoalMessages = event.type === 'agent/inbox/spliced' - ? event.data.inserted.filter(message => message.source.kind === 'goal' - && message.source.round === 0 && !cache.state.insertedChangeMessages.has(message.id)) - : [] applyGoalEvent(cache.state, event) - for (const message of newGoalMessages) { - cache.activation = cache.pendingActivations.get(message.id) ?? 'disarmed' - cache.pendingActivations.delete(message.id) + if (event.type === 'goal/change') { + cache.activation = cache.pendingActivation?.seq === event.seq + ? cache.pendingActivation.activation + : 'disarmed' } cache.observedSeq += 1 } @@ -545,28 +526,20 @@ export class GoalService extends Service { } this.commit(agent, cache, change, activation) const view = this.view(cache) - /* v8 ignore next -- the durable inbox insertion installs the snapshot before this read */ + /* v8 ignore next -- the durable goal event installs the snapshot before this read */ if (view === undefined) throw new Error('snapshot commit cleared the goal unexpectedly') return view } - /** Accept one mutation into the agent injection queue, cache, and live event stream. */ + /** Commit one mutation into the goal log, cache, and live event stream. */ private commit(agent: Agent, cache: GoalCache, change: GoalChangeMeta, activation: GoalActivation): void { const ref = goalChangeRef(change) - const message = createUserMessage({ - content: renderGoalChange(change), - source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change }, - }) - cache.pendingActivations.set(message.id, activation) + cache.pendingActivation = { seq: agent.session.seq, activation } try { - agent.inject(message) - } catch (error: unknown) { - cache.pendingActivations.delete(message.id) - throw error - } - this.sync(agent.session, cache) - if (cache.pendingActivations.delete(message.id)) { - throw new Error('goal injection returned without a durable inbox insertion') + agent.session.append('goal/change', change) + this.sync(agent.session, cache) + } finally { + cache.pendingActivation = undefined } const goal = this.view(cache) const notification: GoalChanged = { diff --git a/packages/goal/goal/src/invariant.ts b/packages/goal/goal/src/invariant.ts index ab2a1001d0..42c83c65f0 100644 --- a/packages/goal/goal/src/invariant.ts +++ b/packages/goal/goal/src/invariant.ts @@ -22,7 +22,6 @@ function cloneState(state: GoalFoldState): GoalFoldState { updatedAt: state.updatedAt, lastRef: state.lastRef, seenGoalIds: new Set(state.seenGoalIds), - insertedChangeMessages: new Map(state.insertedChangeMessages), } } diff --git a/packages/goal/goal/src/render.ts b/packages/goal/goal/src/render.ts deleted file mode 100644 index c269276d25..0000000000 --- a/packages/goal/goal/src/render.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** Model-visible rendering for durable goal mutations. */ - -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { GoalChangeMeta } from './domain.ts' - -/** - * Render a complete goal snapshot or clear tombstone without hidden prose. - * @param change - durable goal change carried by the message source. - * @returns the single context block logged and projected verbatim for model reconstruction. - */ -export function renderGoalChange(change: GoalChangeMeta): ContentBlock[] { - const payload = change.operation === 'clear' - ? { cleared: change.cleared, clearedAt: change.clearedAt } - : { - goal: change.goal, - roundsStarted: change.roundsStarted, - createdAt: change.createdAt, - updatedAt: change.updatedAt, - } - return [{ type: 'text', text: `${JSON.stringify(payload)}` }] -} diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts index 89d85ca5b6..25e22bd5b2 100644 --- a/packages/goal/goal/src/types.ts +++ b/packages/goal/goal/src/types.ts @@ -52,7 +52,7 @@ export interface GoalSnapshot extends GoalRef { /** * The `goal` projection value: the current durable goal with its replay - * counters, exactly as the latest `goal/change` source carried them. + * counters, exactly as the latest `goal/change` event carried them. * Activation is process-local (never persisted) and deliberately absent — * the projection reflects durable phase only. */ diff --git a/packages/goal/goal/tests/goal.e2e.ts b/packages/goal/goal/tests/goal.e2e.ts index 2be460fa87..92a155c2cd 100644 --- a/packages/goal/goal/tests/goal.e2e.ts +++ b/packages/goal/goal/tests/goal.e2e.ts @@ -3,7 +3,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { decodeGoalChange, renderGoalChange } from '@deepseek-ai/dsh-goal' +import { decodeGoalChange } from '@deepseek-ai/dsh-goal' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url)) @@ -49,14 +49,11 @@ describe('goal domain through a real cordis.yml and headless process', () => { expect(result['output']).toContain('CLI tool round trip complete') expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1) - const contexts = events.filter(event => event.type === 'user/message' - && event.data.source.kind === 'goal') - expect(contexts).toHaveLength(1) - const context = contexts[0] - if (context?.type !== 'user/message') throw new Error('expected goal context event') - const change = context.data.source.kind === 'goal' - ? decodeGoalChange(context.data.source.change) - : undefined + const changes = events.filter(event => event.type === 'goal/change') + expect(changes).toHaveLength(1) + const context = changes[0] + if (context?.type !== 'goal/change') throw new Error('expected goal change event') + const change = decodeGoalChange(context.data) if (change === undefined) throw new Error('expected durable goal change') expect(change).toMatchObject({ operation: 'create', @@ -68,10 +65,9 @@ describe('goal domain through a real cordis.yml and headless process', () => { maxGoalRounds: 7, }, }) - expect(context.data.content).toEqual(renderGoalChange(change)) expect(JSON.stringify(context)).not.toContain('activation') - // No admitted continuation round ran (the snapshot mounts without starting - // a round); the round-zero state change from create is expected above. + // No admitted continuation round ran; the goal change itself is independent + // from model-visible user messages. expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0)).toHaveLength(0) }, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 97e21a0b5c..49d68a4c01 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -2,14 +2,13 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import { createUserMessage, freezeMessage, HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' +import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' import GoalService, { GoalError, GoalId, decodeGoalChange, foldGoal, - renderGoalChange, } from '@deepseek-ai/dsh-goal' import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' @@ -81,7 +80,7 @@ function appendRound(session: Session, ref: GoalRef, round: number): void { } describe('GoalService creation and replay', () => { - it('applies the configured default and writes one verbatim context snapshot', async () => { + it('applies the configured default and writes one durable goal change', async () => { vi.useFakeTimers() vi.setSystemTime(1_700_000_000_000) const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 }) @@ -102,17 +101,14 @@ describe('GoalService creation and replay', () => { }) expect(goal.id).toMatch(/^goal-/) expect(seen).toEqual(['create']) - expect(session.events.map(event => event.type)).toEqual(['agent/inbox/spliced']) + expect(session.events.map(event => event.type)).toEqual(['goal/change']) const context = session.events[0] - expect(context?.type).toBe('agent/inbox/spliced') - if (context?.type !== 'agent/inbox/spliced') throw new Error('expected queued goal context') - const message = context.data.inserted[0] - if (message === undefined) throw new Error('expected inserted goal context') - expect(message.source).toMatchObject({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 }) - const change = message.source.kind === 'goal' ? decodeGoalChange(message.source.change) : undefined + expect(context?.type).toBe('goal/change') + if (context?.type !== 'goal/change') throw new Error('expected durable goal change') + const change = decodeGoalChange(context.data) if (change === undefined) throw new Error('expected decoded goal change') expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } }) - expect(message.content).toEqual(renderGoalChange(change)) + expect(agent.inbox.nextStep).toEqual([]) expect(session.deriveMessages()).toEqual([]) expect(foldGoal(session.events)).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 }) vi.useRealTimers() @@ -377,12 +373,8 @@ describe('GoalService mutations', () => { vi.setSystemTime(80) ctx.goals.clear(agent, goal) const clear = session.events - .filter(event => event.type === 'agent/inbox/spliced') - .flatMap(event => event.type === 'agent/inbox/spliced' ? event.data.inserted : []) - .filter(message => message.source.kind === 'goal') - .map(message => message.source.kind === 'goal' - ? decodeGoalChange(message.source.change) - : undefined) + .filter(event => event.type === 'goal/change') + .map(event => event.type === 'goal/change' ? decodeGoalChange(event.data) : undefined) .at(-1) expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 }) expect(() => foldGoal(session.events)).not.toThrow() @@ -400,14 +392,14 @@ describe('GoalService mutations', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer')) }) - it('commits consecutive revisions through synchronous inbox insertions', async () => { + it('commits consecutive revisions through durable goal events', async () => { const { ctx, agent, session } = await harness() let goal = ctx.goals.create(agent, { objective: 'deferred', maxGoalRounds: 5 }) goal = ctx.goals.edit(agent, goal, { objective: 'deferred edit' }) goal = ctx.goals.pause(agent, goal) expect(goal).toMatchObject({ revision: 3, phase: 'paused', activation: 'disarmed' }) expect(session.events.map(event => event.type)).toEqual([ - 'agent/inbox/spliced', 'agent/inbox/spliced', 'agent/inbox/spliced', + 'goal/change', 'goal/change', 'goal/change', ]) expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' }) expect(foldGoal(session.events)).toMatchObject({ goal: { revision: 3, phase: 'paused' } }) @@ -422,8 +414,7 @@ describe('GoalService mutations', () => { ctx.agents.register(stub.agent) let observed: ReturnType ctx.on('session/event', (session, event) => { - if (session === stub.session && event.type === 'agent/inbox/spliced' - && event.data.inserted.some(message => message.source.kind === 'goal')) observed = ctx.goals.get(stub.agent) + if (session === stub.session && event.type === 'goal/change') observed = ctx.goals.get(stub.agent) }) const created = ctx.goals.create(stub.agent, { objective: 'publish once' }) @@ -433,40 +424,20 @@ describe('GoalService mutations', () => { expect(foldGoal(stub.session.events)).toMatchObject({ goal: { id: created.id, revision: 1 } }) }) - it('rolls back a pending mutation when injection rejects before append', async () => { + it('does not delegate goal persistence to agent injection', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) - const stub = stubAgent('goal-rejected-injection') - const append = stub.agent.inject.bind(stub.agent) - let reject = true - stub.agent.inject = (input) => { - if (reject) throw new Error('injection rejected') - append(input) - } + const stub = stubAgent('goal-independent-injection') + stub.agent.inject = () => { throw new Error('injection must not be called') } ctx.agents.register(stub.agent) - expect(() => ctx.goals.create(stub.agent, { objective: 'first attempt' })).toThrow('injection rejected') - reject = false - expect(ctx.goals.create(stub.agent, { objective: 'second attempt' })).toMatchObject({ - objective: 'second attempt', + expect(ctx.goals.create(stub.agent, { objective: 'persist directly' })).toMatchObject({ + objective: 'persist directly', revision: 1, }) - }) - - it('rejects an inject implementation that returns before durable insertion', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(GoalService) - const stub = stubAgent('goal-missing-insertion') - const inject = stub.agent.inject.bind(stub.agent) - stub.agent.inject = () => {} - ctx.agents.register(stub.agent) - - expect(() => ctx.goals.create(stub.agent, { objective: 'missing' })) - .toThrow('without a durable inbox insertion') - stub.agent.inject = inject - expect(ctx.goals.create(stub.agent, { objective: 'committed' })).toMatchObject({ revision: 1 }) + expect(stub.agent.inbox.nextStep).toEqual([]) + expect(stub.session.events.map(event => event.type)).toEqual(['goal/change']) }) it('observes a valid goal snapshot appended after an empty cache was established', async () => { @@ -487,10 +458,7 @@ describe('GoalService mutations', () => { createdAt: 12, updatedAt: 12, } - const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change } as const - appendInjection(session, createUserMessage({ - content: renderGoalChange(change), source, - })) + session.append('goal/change', change) expect(ctx.goals.get(agent)).toMatchObject({ id: change.goal.id, @@ -517,17 +485,8 @@ describe('GoalService mutations', () => { createdAt: 12, updatedAt: 12, } - appendInjection(session, createUserMessage({ - content: renderGoalChange(change), - source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change }, - })) - appendInjection(session, createUserMessage({ - content: [{ type: 'text', text: 'corrupt' }], - source: { - kind: 'goal', goalId: change.goal.id, revision: 2, round: 0, - change: { ...change, operation: 'edit', extra: true } as never, - }, - })) + session.append('goal/change', change) + session.append('goal/change', { ...change, operation: 'edit', extra: true } as never) expect(() => ctx.goals.get(agent)).toThrow('invalid shape') expect(() => ctx.goals.get(agent)).toThrow('invalid shape') @@ -554,32 +513,13 @@ describe('goal replay validation', () => { } } - function appendChange( - session: Session, - change: GoalChangeMeta, - overrides: { content?: ContentBlock[]; source?: MessageSource } = {}, - ): void { - const source = overrides.source ?? { - kind: 'goal', - goalId: change.operation === 'clear' ? change.cleared.id : change.goal.id, - revision: change.operation === 'clear' ? change.cleared.revision : change.goal.revision, - round: 0, - change, - } - const message = createUserMessage({ - content: overrides.content ?? renderGoalChange(change), - source, - }) - appendInjection(session, message) - const turn = nextTurn(session) - session.append('turn/start', { turn }) - session.append('user/message', message, { surfaceOp: 'append' }) - session.append('turn/end', { turn, reason: { kind: 'completed' } }) + function appendChange(session: Session, change: GoalChangeMeta): void { + session.append('goal/change', change) } - function oneChange(change: GoalChangeMeta, overrides: { content?: ContentBlock[]; source?: MessageSource } = {}) { + function oneChange(change: GoalChangeMeta) { const session = new Session(SessionId(`validation-${Math.random()}`)) - appendChange(session, change, overrides) + appendChange(session, change) return session.events } @@ -607,74 +547,21 @@ describe('goal replay validation', () => { } } - it('commits queued changes before admission and verifies the admitted copy without applying it twice', () => { + it('keeps durable goal state independent from inbox changes', () => { const change = snapshotChange() - const message = createUserMessage({ - content: renderGoalChange(change), - source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change }, - }) - const session = new Session(SessionId('queued-change')) - appendInjection(session, message) + const session = new Session(SessionId('inbox-independent-change')) + appendChange(session, change) expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) + const message = createUserMessage({ + content: [{ type: 'text', text: 'unrelated pending context' }], + source: { kind: 'plugin', plugin: 'test' }, + }) const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} }) - expect(inbox.remove('next-step', message.id)).toBe(true) inbox.append('next-step', message) - - session.append('turn/start', { turn: 1 }) - session.append('user/message', message, { surfaceOp: 'append' }) + expect(inbox.remove('next-step', message.id)).toBe(true) expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) }) - it('rejects an admitted change without its inbox insertion', () => { - const change = snapshotChange() - const message = createUserMessage({ - content: renderGoalChange(change), - source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change }, - }) - const session = new Session(SessionId('orphan-admitted-change')) - session.append('user/message', message, { surfaceOp: 'append' }) - expect(() => foldGoal(session.events)).toThrow('was not committed by an inbox insertion') - }) - - it('allows an ordinary admission rewrite but rejects changed goal data under an inserted message id', () => { - const change = snapshotChange() - const message = createUserMessage({ - content: renderGoalChange(change), - source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change }, - }) - const drift = new Session(SessionId('admitted-change-drift')) - appendInjection(drift, message) - drift.append('user/message', freezeMessage({ - ...message, - content: [{ type: 'text', text: 'rewritten as ordinary context' }], - source: { kind: 'plugin', plugin: 'changed-after-claim' }, - }), { surfaceOp: 'append' }) - expect(foldGoal(drift.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) - - const edit = mutation(change, 'edit', 'active') - const changedAdmission = new Session(SessionId('changed-admitted-goal')) - appendInjection(changedAdmission, message) - changedAdmission.append('user/message', freezeMessage({ - ...message, - content: renderGoalChange(edit), - source: { kind: 'goal', goalId: edit.goal.id, revision: 2, round: 0, change: edit }, - }), { surfaceOp: 'append' }) - expect(() => foldGoal(changedAdmission.events)).toThrow('differs from its inbox insertion') - - const reused = new Session(SessionId('reused-change-message-id')) - appendInjection(reused, message) - reused.append('agent/inbox/spliced', { - target: 'next-step', - start: 1, - inserted: [freezeMessage({ - ...message, - content: renderGoalChange(edit), - source: { kind: 'goal', goalId: edit.goal.id, revision: 2, round: 0, change: edit }, - })], - }) - expect(() => foldGoal(reused.events)).toThrow('reuses a message id with different change data') - }) - function foldPair(first: GoalSnapshotChangeMeta, second: GoalChangeMeta): ReturnType { const session = new Session(SessionId(`validation-pair-${Math.random()}`)) appendChange(session, first) @@ -831,7 +718,7 @@ describe('goal replay validation', () => { expect(() => foldGoal(clearedSession.events)).toThrow('fresh active revision-one') }) - it('rejects goal-source context without matching durable metadata', () => { + it('rejects non-positive goal round sources', () => { const session = new Session(SessionId('goal-source-without-meta')) const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const const turn = nextTurn(session) @@ -840,7 +727,7 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'missing' }], source, }), { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) - expect(() => foldGoal(session.events)).toThrow('lacks source change data') + expect(() => foldGoal(session.events)).toThrow('goal message source is invalid') }) it('rejects malformed snapshots, refs, counters, and timestamps', () => { @@ -876,21 +763,6 @@ describe('goal replay validation', () => { })).toThrow('positive safe integer') }) - it('rejects source and content drift from the durable metadata', () => { - const change = snapshotChange() - expect(() => foldGoal(oneChange(change, { source: { kind: 'plugin', plugin: 'wrong' } }))).toThrow('mismatched source') - expect(() => foldGoal(oneChange(change, { - source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: -1 }, - }))).toThrow('source is invalid') - expect(() => foldGoal(oneChange(change, { - source: { kind: 'goal', goalId: GoalId('goal-imposter'), revision: 1, round: 0, change }, - }))).toThrow('mismatched source attribution') - expect(() => foldGoal(oneChange(change, { - source: { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0, change }, - }))).toThrow('mismatched source attribution') - expect(() => foldGoal(oneChange(change, { content: [{ type: 'text', text: 'wrong' }] }))).toThrow('model-visible content') - }) - it('folds a clear tombstone after a snapshot', () => { const change = snapshotChange() const session = new Session(SessionId('fold-clear'), oneChange(change)) diff --git a/packages/goal/goal/tests/invariant.spec.ts b/packages/goal/goal/tests/invariant.spec.ts index af16b7df06..b342036f83 100644 --- a/packages/goal/goal/tests/invariant.spec.ts +++ b/packages/goal/goal/tests/invariant.spec.ts @@ -1,9 +1,8 @@ -import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { GoalId, - renderGoalChange, type GoalSnapshotChangeMeta, } from '@deepseek-ai/dsh-goal' import * as GoalInvariantCompanion from '@deepseek-ai/dsh-goal/invariant' @@ -26,14 +25,6 @@ const change: GoalSnapshotChangeMeta = { updatedAt: 1, } -const changeSource = { - kind: 'goal', - goalId: change.goal.id, - revision: change.goal.revision, - round: 0, - change, -} as const - async function setup(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) @@ -46,17 +37,8 @@ describe('goal stream invariants', () => { it('accepts canonical goal snapshots and sequential admitted rounds', async () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('goal-invariant-valid')) - const message = createUserMessage({ - content: renderGoalChange(change), - source: changeSource, - }) - session.append('agent/inbox/spliced', { - target: 'next-step', start: 0, inserted: [message], - }) + session.append('goal/change', change) session.append('turn/start', { turn: 1 }) - session.append('user/message', message, { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('turn/start', { turn: 2 }) expect(() => { session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'continue' }], @@ -65,25 +47,18 @@ describe('goal stream invariants', () => { }).not.toThrow() }) - it('rejects model-visible drift before committing it and keeps the fold reusable', async () => { + it('rejects a malformed goal change before committing it and keeps the fold reusable', async () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('goal-invariant-invalid')) - const message = createUserMessage({ content: renderGoalChange(change), source: changeSource }) - session.append('agent/inbox/spliced', { - target: 'next-step', start: 0, inserted: [message], - }) - session.append('turn/start', { turn: 1 }) expect(() => { - session.append('user/message', freezeMessage({ ...message, - content: [{ type: 'text', text: 'counterfeit' }], - }), { surfaceOp: 'append' }) + session.append('goal/change', { ...change, extra: true } as never) }).toThrow(expect.objectContaining>({ code: 'INVARIANT', packageName: '@deepseek-ai/dsh-goal', })) - expect(session.seq).toBe(2) + expect(session.seq).toBe(0) expect(() => { - session.append('user/message', message, { surfaceOp: 'append' }) + session.append('goal/change', change) }).not.toThrow() }) @@ -91,17 +66,11 @@ describe('goal stream invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('goal-invariant-late-load')) - const message = createUserMessage({ content: renderGoalChange(change), source: changeSource }) - session.append('agent/inbox/spliced', { - target: 'next-step', start: 0, inserted: [message], - }) - session.append('turn/start', { turn: 1 }) - session.append('user/message', message, { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('goal/change', change) await ctx.plugin(InvariantService, { enabled: true }) await ctx.plugin(GoalInvariantCompanion) - session.append('turn/start', { turn: 2 }) + session.append('turn/start', { turn: 1 }) expect(() => { session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'continue after load' }], diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 7365d8e699..0435cf50b7 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -125,41 +125,20 @@ describe('goal projection unit', () => { } }) - it('does not revive a cleared goal when its create message is reinserted', async () => { + it('does not let inbox changes revive a cleared goal', async () => { const bench = await harness(true) const created = bench.ctx.goals.create(bench.agent, { objective: 'stay cleared' }) - const createMessage = bench.agent.inbox.nextStep.find(message => message.source.kind === 'goal' - && message.source.change?.operation === 'create') - if (createMessage === undefined) throw new Error('missing create message') bench.ctx.goals.clear(bench.agent, created) - bench.agent.inbox.claim('next-step') - bench.agent.inbox.prepend('next-step', createMessage) + bench.agent.inbox.prepend('next-step', createUserMessage({ + content: [{ type: 'text', text: 'unrelated pending context' }], + source: { kind: 'plugin', plugin: 'test' }, + })) expect(bench.tailValues().goal).toBeNull() expect(foldGoal(bench.session.events).goal).toBeUndefined() }) - it('does not regress a goal revision when its create message is reinserted', async () => { - const bench = await harness(true) - const created = bench.ctx.goals.create(bench.agent, { objective: 'first revision' }) - const createMessage = bench.agent.inbox.nextStep.find(message => message.source.kind === 'goal' - && message.source.change?.operation === 'create') - if (createMessage === undefined) throw new Error('missing create message') - const edited = bench.ctx.goals.edit(bench.agent, created, { objective: 'second revision' }) - - bench.agent.inbox.claim('next-step') - bench.agent.inbox.prepend('next-step', createMessage) - - expect(bench.tailValues().goal).toMatchObject({ - goal: { revision: edited.revision, objective: 'second revision' }, - }) - expect(foldGoal(bench.session.events).goal).toMatchObject({ - revision: edited.revision, - objective: 'second revision', - }) - }) - it('ignores non-goal and malformed goal-shaped events fail-soft (same reference)', () => { // The package invariant rejects a violating stream loudly wherever it is // installed — the unit itself must never throw on the projection drive @@ -171,28 +150,22 @@ describe('goal projection unit', () => { }) const user = { type: 'user/message', seq: 0, time: 1, data: plainUser } as never const state = { goal: { id: 'g1', revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 4 }, roundsStarted: 0, createdAt: 1, updatedAt: 1 } as never - const empty = [null, []] as const + const empty = null expect(applyGoalProjection(empty, user)).toBe(empty) const queuedUser = { type: 'agent/inbox/spliced', seq: 1, time: 2, data: { target: 'next-step', start: 0, inserted: [plainUser] }, } as never - const current = [state, []] as const + const current = state expect(applyGoalProjection(current, queuedUser)).toBe(current) - const malformedMessage = createUserMessage({ - content: [{ type: 'text', text: 'broken' }], - source: { kind: 'goal', goalId: 'g-broken', revision: 1, round: 0 } as never, - }) - const malformed = { type: 'user/message', seq: 1, time: 2, data: malformedMessage } as never + const malformed = { + type: 'goal/change', seq: 1, time: 2, + data: { kind: 'goal/change', version: 1, operation: 'create' }, + } as never // Same-reference return: the registry's Object.is gate sees no change. expect(applyGoalProjection(current, malformed)).toBe(current) expect(applyGoalProjection(empty, malformed)).toBe(empty) - const queuedMalformed = { - type: 'agent/inbox/spliced', seq: 2, time: 3, - data: { target: 'next-step', start: 0, inserted: [malformedMessage] }, - } as never - expect(applyGoalProjection(current, queuedMalformed)).toBe(current) const queuedRound = { type: 'agent/inbox/spliced', seq: 3, time: 4, @@ -203,29 +176,14 @@ describe('goal projection unit', () => { } as never expect(applyGoalProjection(current, queuedRound)).toBe(current) - const validGoalUser = { type: 'user/message', seq: 2, time: 3, data: createUserMessage({ - content: [{ type: 'text', text: 'legacy direct change' }], - source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0, change: { kind: 'goal/change' } } as never, - }) } as never - expect(applyGoalProjection(empty, validGoalUser)).toBe(empty) - // A non-message event (the registry drives EVERY committed event through // apply): early same-reference return. const turnStart = { type: 'turn/start', seq: 3, time: 4, data: { turn: 1 } } as never expect(applyGoalProjection(current, turnStart)).toBe(current) - // A round-zero goal source whose change carries a foreign kind: same posture. - const foreignMessage = createUserMessage({ - content: [{ type: 'text', text: 'foreign' }], - source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0, change: { kind: 'not-a-goal-change' } } as never, - }) - const foreignKind = { type: 'user/message', seq: 2, time: 3, data: foreignMessage } as never + // A goal/change event whose payload carries a foreign kind is ignored. + const foreignKind = { type: 'goal/change', seq: 4, time: 5, data: { kind: 'not-a-goal-change' } } as never expect(applyGoalProjection(current, foreignKind)).toBe(current) - const queuedForeignKind = { - type: 'agent/inbox/spliced', seq: 4, time: 5, - data: { target: 'next-step', start: 0, inserted: [foreignMessage] }, - } as never - expect(applyGoalProjection(current, queuedForeignKind)).toBe(current) }) it('has no goal key when the goal service is not composed', async () => { diff --git a/packages/goal/tool-goal/README.i18n.yaml b/packages/goal/tool-goal/README.i18n.yaml index e4c9fa59d5..2376ae972d 100644 --- a/packages/goal/tool-goal/README.i18n.yaml +++ b/packages/goal/tool-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/tool-goal/README.md -README.md: a4742e4117ca89f4395a1c59264f6ea6c3ab8b96 -README.zh.md: 48e89332db7dfbe746d1ba4e57077eb787a10a66 +README.md: c8c1ab84c237ee34db7abcd63962476693b5e56c +README.zh.md: b90a8af79c35986e1fc86be90370a214ab517185 diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index a4742e4117..c8c1ab84c2 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -61,15 +61,15 @@ Prefix-stable while the plugin scope, configured threshold, and guidance text ar #### What the model sees -The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. A mutation queues the goal domain's raw `` snapshot after the tool batch; a later pre-step may admit it, while discarding the queued context does not roll back the durable mutation. `activation` in a result is a live observation and never becomes replay authority. +The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. A mutation appends the goal domain's durable `goal/change` event without queuing model context. `activation` in a result is a live observation and never becomes replay authority. #### Token effect -Fixed schema cost plus one compact result per call. An admitted mutation context retains the domain snapshot until compaction; one discarded before admission adds no model tokens. +Fixed schema cost plus one compact result per call. The durable mutation adds no separate model-visible context. #### KV Cache effect -Schemas are prefix-stable while their definitions and visibility are unchanged. Calls, results, and admitted goal snapshots append after the reusable request prefix without invalidating earlier entries. +Schemas are prefix-stable while their definitions and visibility are unchanged. Calls and results append after the reusable request prefix without invalidating earlier entries. ## Known Limitations and Deferred Work diff --git a/packages/goal/tool-goal/README.zh.md b/packages/goal/tool-goal/README.zh.md index 48e89332db..b90a8af79c 100644 --- a/packages/goal/tool-goal/README.zh.md +++ b/packages/goal/tool-goal/README.zh.md @@ -61,15 +61,15 @@ Use goal tools for one long-running completion objective in the current session. #### 模型看到的内容 -生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更会在工具批次结束后将 goal 领域的原始 `` 快照排队;后续 pre-step 可以准入它,而丢弃已排队的上下文不会回滚持久变更。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。 +生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更会追加 goal 领域的持久 `goal/change` 事件,而不会把模型上下文排队。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。 #### Token 影响 -固定 schema 成本,加上每次调用的一条紧凑结果。获准的变更上下文会保留领域快照,直到压缩(compaction);准入前被丢弃的上下文不增加模型 token。 +固定 schema 成本,加上每次调用的一条紧凑结果。持久变更不会增加单独的模型可见上下文。 #### KV Cache 影响 -schema 的定义与可见性不变时,前缀保持稳定。调用、结果和已准入的 goal 快照会追加到可复用请求前缀之后,不会使更早条目失效。 +schema 的定义与可见性不变时,前缀保持稳定。调用和结果会追加到可复用请求前缀之后,不会使更早条目失效。 ## 已知限制与暂缓事项 diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 0ae603a0ce..cd767868c3 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -16,7 +16,7 @@ import { createUserMessage, type LlmModelReasoningInfo, createMessage, } from '@deepseek-ai/dsh-llm' -import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' +import { GOAL_CHANGE_VERSION, GoalId, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact' import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session' @@ -1239,19 +1239,7 @@ describe('pi-tui chat lifecycle and transcript', () => { } const result = await setup({ beforeMount(session) { - const message = createUserMessage({ - content: renderGoalChange(change), - source: { - kind: 'goal', - goalId: change.goal.id, - revision: change.goal.revision, - round: 0, - change, - }, - }) - session.append('agent/inbox/spliced', { - target: 'next-step', start: 0, inserted: [message], - }) + session.append('goal/change', change) }, }) expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed') @@ -1380,7 +1368,7 @@ describe('pi-tui chat lifecycle and transcript', () => { // A non-plugin injected source (goal) has no `plugin` field, so its context // card label falls back to the source kind. result.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'goal context' }], source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0 } as never, + content: [{ type: 'text', text: 'goal context' }], source: { kind: 'goal', goalId: 'g1', revision: 1, round: 1 } as never, }), { surfaceOp: 'append' }) appendAssistant(result.session, []) result.session.append('step/end', { turn: 1, step: 1 }) diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 786e5ad5af..42abc5a86c 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -296,7 +296,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ dir: 'tool-goal', source: 'packages/goal/tool-goal/src/index.ts', requires: ['ctx.tools', 'ctx.agents', 'ctx.goals', 'ctx.systemPrompt', 'a calling Agent in an authorized open turn'], - writes: ['tool/call', 'agent/inbox/spliced goal snapshot for mutations', 'tool/result'], + writes: ['tool/call', 'goal/change for mutations', 'tool/result'], async mount(ctx) { await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService)