Merge remote-tracking branch 'origin/worktree/context-source-cards' into worktree/context-forms-remaining

# Conflicts:
#	apps/web/tests/snapshots/queue-actions/layout.expected.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/goal.i18n.yaml
#	docs/core-data-structures/goal.md
#	docs/core-data-structures/goal.zh.md
#	docs/event-producer-consumer.md
#	examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl
#	examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/bash-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl
#	examples/acp-agent/tests/snapshots/cancel/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl
#	examples/acp-agent/tests/snapshots/error-finish/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-edit/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-read/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl
#	examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl
#	examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl
#	examples/acp-agent/tests/snapshots/multi-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl
#	examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl
#	examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl
#	examples/acp-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl
#	examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl
#	examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-report/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/todo-write/session.jsonl
#	examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/web-fetch/session.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl
#	examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl
#	packages/bash/tool-bash/tests/integration.spec.ts
#	packages/context/time-context/src/index.ts
#	packages/context/tmux-context/src/index.ts
#	packages/core/agent-loop/src/agent.ts
#	packages/core/system-prompt/src/index.ts
#	packages/goal/goal/src/domain.ts
#	packages/goal/goal/src/index.ts
#	packages/goal/goal/src/render.ts
#	packages/plan/plan-mode/src/index.ts
This commit is contained in:
creatixchu
2026-08-06 11:49:03 +08:00
1357 changed files with 27674 additions and 18803 deletions

View File

@@ -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/README.md
README.md: f43dfd8258eabe8342207c0b1b9d6acc9e215e9f
README.zh.md: 70b38caac723d757f44109e4ec75e3c31e7c34b8
README.md: 9fc6b0c18b1862a8be08275785ea3b6185e9bdbc
README.zh.md: 08f15bcc4e405e25d4dfd5981bbc99408833403c

View File

@@ -6,9 +6,9 @@ The goal family owns durable objective state independently of the model-facing t
| Package | Role | ctx key |
|---|---|---|
| `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` |
| `goal-session/` | Same-session goal-round admission, outcome mapping, and lifecycle race fencing | — |
| `tool-goal/` | Model-facing read/create/update tools with execution-time authority checks | — |
| `command-goal/` | Human-facing `/goal` status and lifecycle control over the command plane | — |
| [`goal/`](goal/README.md) | Goal state and lifecycle | `ctx.goals` |
| [`goal-session/`](goal-session/README.md) | Same-session goal continuation | — |
| [`tool-goal/`](tool-goal/README.md) | Model-facing goal tools | — |
| [`command-goal/`](command-goal/README.md) | Human-facing goal command | — |
Goal state is part of the owning session log. Consumers depend on `dsh-goal`, not on the concrete agent loop; continuation behavior belongs in a separate plugin on the public agent seams.

View File

@@ -6,9 +6,9 @@ goal 家族负责持久目标状态,与消费该状态的面向模型工具和
| 包 | 职责 | ctx 键 |
|---|---|---|
| `goal/` | 事件溯源的目标生命周期、回放折叠、比较并设置变更,以及进程本地激活 | `ctx.goals` |
| `goal-session/` | 同会话 Goal Round 的准入、结果映射与生命周期竞态隔离 | 无 |
| `tool-goal/` | 面向模型的读取/创建/更新工具,并在执行时检查权限 | 无 |
| `command-goal/` | 面向用户的 `/goal` 状态,以及通过命令平面执行的生命周期控制 | 无 |
| [`goal/`](goal/README.md) | 目标状态与生命周期 | `ctx.goals` |
| [`goal-session/`](goal-session/README.md) | 同会话目标续行 | 无 |
| [`tool-goal/`](tool-goal/README.md) | 面向模型的目标工具 | 无 |
| [`command-goal/`](command-goal/README.md) | 面向用户的目标命令 | 无 |
目标状态是其所属会话日志的一部分。消费方依赖 `dsh-goal`,而不是具体的 agent loop智能体循环续行行为由基于公开 agent seam 的独立插件负责。

View File

@@ -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: 8e1a5b417467c8701ea935e25acfece11c5a70d4
README.zh.md: ddb11d09d4a4d560af05b05ad7b586c1b0d365f9
README.md: a02803b3ef7f93f0c4910ec2be662cc7836d9048
README.zh.md: 9c6d0cc6e7309a138e17f1a2d5ad6c5285913394

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI executes it without a model turn. The [human goal-command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions.
Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers and executes it without a model turn. The [human goal-command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions.
## Command contract
@@ -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; every accepted mutation is persisted and made model-visible by `dsh-goal` rather than by this plugin.
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
@@ -32,7 +32,7 @@ The producer injects `commands` and `goals`. A custom app mounts their owners pl
name: '@deepseek-ai/dsh-command-goal'
```
The TUI app enables the complete persisted-goal stack and this command by default. The ACP automation app enables the domain and model tools without mounting the command registry; `goals: false` removes that stack. The UI-less `agent-spine-demo` requires an explicit `goals: {}` so headless one-shot callers do not silently change from one physical turn to a multi-round operation.
The shipped `dsh` base enables the persisted-goal stack and this command; the Web client provides its interactive adapter. The ACP automation app enables the domain and model tools without a command adapter; `goals: false` removes that stack. The UI-less `agent-spine-demo` requires an explicit `goals: {}` so headless one-shot callers do not silently change from one physical turn to a multi-round operation.
## Model Experience
@@ -40,19 +40,19 @@ 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 later appears through the goal domain's raw `<goal_state>` snapshot or clear tombstone; this preserves the model-visible-is-logged invariant without logging presentation text.
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. Each accepted mutation adds the goal domain's retained full snapshot, and 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. A mutation 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
- **Plain-text interaction only** — the generic command registry has no modal edit form or replacement-confirmation callback; inline edit and explicit clear keep destructive intent deterministic across adapters.
- **No per-command round-cap argument** — `defaultMaxGoalRounds` remains deployment config, while a direct human request may ask the model to edit `max_goal_rounds` through the separately authorized goal tool.
- **No continuous status widget** — bare `/goal` is the portable observation surface; adapter-specific badges and reconnectable command output remain future UI work.
- **TUI only in the shipped apps** — the headless CLI, ACP automation, and JSON-RPC adapters do not consume `ctx.commands`. Ordinary prompts can still authorize model-facing goal tools when those are composed.
- **Web command adapter only in the shipped apps** — headless, ACP automation, and JSON-RPC adapters do not consume `ctx.commands`. Ordinary prompts can still authorize model-facing goal tools when those are composed.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
面向用户的 `/goal` 控制,基于 [`ctx.goals`](../goal/README.md) 实现。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此每个已组合的命令适配器都能发现它;随附 TUI 无需模型轮次即可执行。[用户 goal 命令 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) 负责用户体验与组合决策。
面向用户的 `/goal` 控制,基于 [`ctx.goals`](../goal/README.md) 实现。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此每个已组合的命令适配器都能发现并执行它,无需模型轮次。[用户 goal 命令 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md)负责用户体验与组合决策。
## 命令契约
@@ -17,7 +17,7 @@
只有控制词占据完整输入时才不区分大小写。其他任何非空后缀都属于目标,因此 `/goal pause after verification` 会创建该字面目标。goal 领域会去除目标首尾空白并进行验证。由于通用命令平面没有模态编辑器或确认原语,`edit` 会内联接收替换内容;若试图替换未完成的 goal则直接返回错误提示用户执行 edit 或 clear。
可预期的领域拒绝会变成稳定的直接命令错误,不公开带品牌类型的 id 或 revision。意外实现失败仍会 reject 分发,使适配器能将其报告为命令失败。通用命令文本和输出仍属于实时 UI 状态;每项已接受变更都由 `dsh-goal` 持久化并提供给模型,而不是由此插件完成
可预期的领域拒绝会变成稳定的直接命令错误,不公开带品牌类型的 id 或 revision。意外实现失败仍会 reject 分发,使适配器能将其报告为命令失败。通用命令文本和输出仍属于实时 UI 状态;`dsh-goal` 通过自有的持久 `goal/change` 事件记录每项已接受变更
## 组合
@@ -32,7 +32,7 @@
name: '@deepseek-ai/dsh-command-goal'
```
TUI 应用默认启用完整的持久 goal 栈和此命令。ACPAgent Client Protocol自动化应用启用领域与模型工具,但不挂载命令注册表`goals: false` 会移除该栈。无 UI 的 `agent-spine-demo` 必须显式配置 `goals: {}`,避免无头单次调用方在不知情时从一个物理轮次变为包含多个 Round 的操作。
随附 `dsh` 基础配置启用持久 goal 栈和此命令Web 客户端提供其交互适配器。ACPAgent Client Protocol自动化应用启用领域与模型工具但不挂载命令适配器`goals: false` 会移除该栈。无 UI 的 `agent-spine-demo` 必须显式配置 `goals: {}`,避免无头单次调用方在不知情时从一个物理轮次变为包含多个 Round 的操作。
## 模型体验
@@ -40,19 +40,19 @@ TUI 应用默认启用完整的持久 goal 栈和此命令。ACPAgent Client
#### 模型看到的内容
斜杠输入直接状态/错误输出不会进入模型请求。已接受的变更稍后会通过 goal 领域的原始 `<goal_state>` 快照或 clear tombstone 出现;这样既满足模型可见内容必须记录日志的不变量,也无需记录呈现文本
斜杠输入、变更以及直接状态/错误输出不会进入模型请求。Goal 领域把变更记录为 `goal/change`;已启用的同会话驱动器可以在后续继续执行提示词中暴露结果状态。呈现文本绝不会记录日志
#### Token 影响
读取状态或收到直接命令错误不会增加模型 token。每项已接受变更都会增加 goal 领域保留的完整快照;已启用的同会话驱动器可能增加后续 Goal Round 提示词。
读取状态、变更 goal 或收到直接命令错误不会增加模型 token。已启用的同会话驱动器可能增加后续 Goal Round 提示词。
#### KV Cache 影响
命令发现与直接输出不会影响缓存。变更会追加到可复用历史前缀之后;后续压缩可能替换派生历史后缀
命令发现、变更与直接输出不会影响缓存。后续继续执行提示词遵循驱动器的普通请求历史
## 已知限制与暂缓事项
- **仅纯文本交互**:通用命令注册表没有模态编辑表单或替换确认回调;内联 edit 与显式 clear 能在不同适配器中保持明确且一致的破坏性意图。
- **没有逐命令 Round 上限参数**`defaultMaxGoalRounds` 仍是部署配置;用户直接请求时,可以要求模型通过另行授权的 goal 工具编辑 `max_goal_rounds`
- **没有持续状态组件**:裸 `/goal` 是可移植的观察接口;适配器专用徽标和重连后可恢复的命令输出仍属于未来 UI 工作。
- **随附应用中只有 TUI 使用此命令**:无头 CLI命令行界面、ACP 自动化和 JSON-RPC 适配器不消费 `ctx.commands`。如果组合中包含面向模型的 goal 工具,普通提示词仍能授权它们。
- **随附应用中只有 Web 命令适配器使用此命令**无头、ACP 自动化和 JSON-RPC 适配器不消费 `ctx.commands`。如果组合中包含面向模型的 goal 工具,普通提示词仍能授权它们。

View File

@@ -1,12 +1,12 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import AgentRegistry, {} from '@deepseek-ai/dsh-agent'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import GoalService from '@deepseek-ai/dsh-goal'
import type { GoalRef } from '@deepseek-ai/dsh-goal'
import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
interface Harness {
@@ -16,34 +16,25 @@ interface Harness {
readonly plugin: Awaited<ReturnType<Context['plugin']>>
}
/** Append one idle injection using the public Agent contract (idle inject wraps in a one-shot injection turn, per turn enclosure). */
function appendInjection(session: Session, input: UserMessage): void {
const lastStart = session.events.findLast(event => event.type === 'turn/start')
const turn = (lastStart?.data.turn ?? 0) + 1
session.append('turn/start', { turn, trigger: { kind: 'injection', source: input.source } })
session.append('user/message', input, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
/** Build a live idle agent accepted by the exact-identity goal service. */
function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
// Store-created: the command executor durably logs lifecycle events on it.
const session = ctx.sessions.create(SessionId(id))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
let status: AgentStatus = 'idle'
const agent: Agent = {
id: session.id,
options: {},
session,
inbox,
ctx: new Context(),
get status() { return status },
get acceptsNextStep() { return status === 'running' },
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(input) { appendInjection(session, input) },
reserveTurnAdmission: () => undefined,
steer: () => {},
inject(input) { inbox.append('next-step', input) },
cancel() { status = 'idle' },
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
return { agent, session }
@@ -133,7 +124,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(['turn/start', 'user/message', 'turn/end'])
expect(domainEvents(test.session).map(event => event.type)).toEqual(['goal/change'])
const count = domainEvents(test.session).length
await expect(run(test, ' replacement')).resolves.toEqual({

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/goal/goal-session/README.md
README.md: 6a1c3b9455c93762c2458109c753588ce9a08d9a
README.zh.md: 7e8b04f46f0345bce026ccd8c663882f27e036ab
README.md: 89413062de8cbb49d7066ec3ae42769a99c939a2
README.zh.md: 45f61f2ea02b4a8112a88a3425ef3893128b3098

View File

@@ -21,32 +21,23 @@ The plugin has no tunable configuration. `maxGoalRounds` belongs to the goal def
## Round contract
When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `<goal_round>` prompt with `GoalMessageSource`. Admission through `agent/prompt-submit` verifies the complete queued record and current goal both before and after downstream prompt hooks; only the accepted `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number.
When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `<goal_round>` prompt with `GoalMessageSource`. The `agent/pre-step` listener verifies the complete claimed record and current goal both before and after downstream listeners; only an entered `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number.
One goal round owns one ordinary session turn, and that turn may contain several model/tool steps. The driver pairs a reservation only with a `message` turn carrying its exact `GoalMessageSource`; merge-extensible plugin turn triggers do not admit or replace that reservation. Human messages remain ordinary turns and do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until that work settles; a pending automatic prompt in a mixed batch is rejected and re-reserved only after the agent becomes idle.
`MessageId` identifies the reserved message through durable inbox insertion and claim; it does not identify a turn result. Human messages do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until the agent becomes idle; a pending automatic prompt in a mixed batch is rejected and re-reserved only after that checkpoint.
The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, treats the current workspace, tool results, and durable session state as authoritative, requires evidence before completion, and tells the model to leave the goal active when work remains. Quoting preserves multiline or tag-like objective text as data. Goal lifecycle mutations still require the independent authority checks in `dsh-tool-goal`.
## Settlement policy
## Idle checkpoint
| Durable turn outcome | Goal action | Automatic retry |
|---|---|---|
| `completed` with goal still active and armed | admit the next round, or block with code `round-limit` at the cap | yes |
| cancellation of a reserved/admitted goal round, or its `aborted` outcome | `paused` | no |
| cancellation with no goal-round attempt | keep durable phase; disarm activation | no |
| `error` with `RATE_LIMIT` or `QUOTA` | `blocked` with code `usage-limited` | no |
| other `error`, `max-tokens`, or a non-stale prompt rejection | `blocked` with a diagnostic code and message | no |
| durability failure, disposal, interruption, or unknown future outcome | disarm or block for inspection | no |
A goal mutation made during its round supersedes settlement of the older revision. Completion, pause, blocking, and edits therefore remain authoritative even if the physical turn closes afterward. No abnormal result is retried automatically.
At whole-agent idle, durable goal phase and revision are authoritative. An active, armed goal with capacity reserves its next round; completion, pause, blocking, and edits suppress continuation. The driver does not classify the preceding activity by correlating the goal message with `turn/end`, so provider errors and token limits are not prompt-level goal outcomes.
## Lifecycle and durability
`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A closing flush failure arrives through `agent/error`; the driver associates it with the exact closed turn even if a later one-shot injection has appended another turn, then disarms before another round can start.
`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A flush failure arriving through `agent/error` disarms continuation before another round can start.
Activation is never inherited when this plugin loads over an existing agent. `GoalService.disarm()` removes process-local authority without changing durable phase, revision, or history; explicit human-authorized resume records the later reactivation. The same rule applies after session resume and fork through the goal domain's `agent/session-start` handling.
Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` with its typed cause before clearing queues or aborting the turn. The plugin durably pauses an active goal only when the cancellation owns a reserved or admitted goal attempt; cancellation of unrelated human work merely disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels an admitted round with the `parent` cause, and awaits the driver plus agent quiescence while its event fence remains installed.
Cancellation removes pending inbox work or leaves an agent-wide aborted state. At the next idle checkpoint the driver pauses a goal with a reserved or admitted attempt so cancellation cannot auto-restart it; cancellation unrelated to a goal attempt only disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels active work with the `parent` cause, and awaits the driver plus agent quiescence while its event fence remains installed.
## Model Experience
@@ -69,5 +60,5 @@ Append-only within an epoch: each admitted round extends the existing conversati
- **No independent evaluator** — the model-facing goal policy decides when evidence is sufficient for completion and whether a blocker is semantically unchanged; evaluator-backed certification remains deferred.
- **Same-session execution only** — this package deliberately does not spawn a fresh agent, fork a session prefix, or implement Ralph-style independent attempts; that workflow belongs to its own plugin layer.
- **Accepted-queue unload race** — Cordis plugin unload is asynchronous. A goal prompt already accepted by the agent inbox can begin and consume its round before unload starts; teardown then cancels the request, disarms the goal, and awaits quiescence. No later round starts.
- **Round cap, not resource budget** — token, currency, time, and provider quota policies remain independent; observed `RATE_LIMIT` and `QUOTA` stops only map into the blocked reason code `usage-limited`.
- **Round cap, not resource budget** — token, currency, time, and provider quota policies remain independent. Their session events are not attributed to the goal message or mapped into goal blocker codes.
- **No abnormal auto-retry** — transient provider and persistence failures require a later human-authorized resume rather than an implicit retry policy.

View File

@@ -21,32 +21,23 @@
## Round 契约
当对应的活跃 agent 实例处于 idle 状态,且目标 phase 为 active、已启用续行并有剩余容量时驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `<goal_round>` 提示词,并携带 `GoalMessageSource`通过 `agent/prompt-submit` 准入时,会在下游提示词钩子前后验证完整的排队记录与当前 goal只有被接受`user/message` 才会增加 `roundsStarted`。因陈旧而被拒绝的预留不会消耗 Round 编号。
当对应的活跃 agent 实例处于 idle 状态,且目标 phase 为 active、已启用续行并有剩余容量时驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `<goal_round>` 提示词,并携带 `GoalMessageSource``agent/pre-step` 监听器会在下游监听器前后验证完整的已领取记录与当前 goal只有进入步骤`user/message` 才会增加 `roundsStarted`。因陈旧而被拒绝的预留不会消耗 Round 编号。
一个 Goal Round 对应一个普通会话轮次,该轮次可以包含多个模型/工具步骤。驱动器只会把预留与 `message` 轮次配对,且该轮次必须携带完全相同的 `GoalMessageSource`;可通过声明合并扩展的插件轮次触发器不会准入或替换该预留。用户消息仍是普通轮次,不消耗 goal 上限。如果用户工作在预留前进入 inbox或加入预留的待处理批次自动工作会让行直到用户工作结算;混合批次中的待处理自动提示词会被拒绝,只有 agent 再次 idle 后才重新预留。
`MessageId` 通过持久 inbox 插入和领取来标识预留消息;它不标识轮次结果。用户消息不消耗 goal 上限。如果用户工作在预留前进入 inbox或加入预留的待处理批次自动工作会让行直到 agent 进入 idle;混合批次中的待处理自动提示词会被拒绝,只有完成该检查点后才重新预留。
保留的提示词会点明经过 JSON 引用的目标与 `round/maxGoalRounds`,将当前工作区、工具结果和持久会话状态视为权威信息,要求在完成前提供证据,并要求在工作仍未完成时保持目标 active。引用可将多行或形似标签的目标文本保留为数据。goal 生命周期变更仍必须通过 `dsh-tool-goal` 的独立权限检查。
## 结算策略
## Idle 检查点
| 持久轮次结果 | Goal 操作 | 自动重试 |
|---|---|---|
| goal phase 仍为 active 且已启用续行时的 `completed` | 准入下一 Round达到上限时以代码 `round-limit` 阻塞 | 是 |
| 已预留/准入 Goal Round 的取消,或其 `aborted` 结果 | `paused` | 否 |
| 未尝试 Goal Round 时取消 | 保留持久 phase撤销激活 | 否 |
| `error` 且带 `RATE_LIMIT``QUOTA` | 设为 `blocked`,代码为 `usage-limited` | 否 |
| 其他 `error``max-tokens` 或非陈旧提示词拒绝 | 以诊断代码和消息设为 `blocked` | 否 |
| 持久性失败、dispose资源释放、中断或未知未来结果 | 撤销激活或阻塞,以便检查 | 否 |
某个 goal 在自身 Round 中发生的变更,会取代旧 revision 的结算。因此,即使物理轮次随后关闭,完成、暂停、阻塞和编辑仍具有最终决定权。任何异常结果都不会自动重试。
整个 agent 进入 idle 时,持久 goal phase 和 revision 具有权威性。phase 为 active、已启用续行且仍有容量的 goal 会预留下一 Round完成、暂停、阻塞和编辑都会阻止续行。驱动器不会通过关联 goal 消息与 `turn/end` 来对前一段活动分类,因此提供方错误和 token 上限不属于提示词级 goal 结果。
## 生命周期与持久性
`goal/changed` 会产生持久性义务。排队工作前,驱动器会等待 `ctx.sessions.flush()`,并在等待后重新检查 goal revision 与竞争输入。关闭时的 flush 失败通过 `agent/error` 到达;即使后续一次性注入已经追加另一轮次,驱动器仍会把失败关联到完全相同的已关闭轮次,然后停用续行,避免另一 Round 启动。
`goal/changed` 会产生持久性义务。排队工作前,驱动器会等待 `ctx.sessions.flush()`,并在等待后重新检查 goal revision 与竞争输入。通过 `agent/error` 到达的 flush 失败会停用续行,避免另一 Round 启动。
此插件加载到现有 agent 上时绝不会继承续行启用状态。`GoalService.disarm()` 会移除进程本地权限,而不改变持久 phase、revision 或历史;之后由用户明确授权的 resume 会记录重新启用续行。会话 resume 和 fork 后goal 领域通过 `agent/session-start` 处理应用相同规则。
取消采用先观察、后行动的顺序:具体循环会在清空队列或中止轮次前,发送带类型 cause 的 `agent/cancel-requested`。仅当取消操作所针对的是已预留或已准入的 Goal Round 尝试时,插件才会持久暂停 active goal取消无关用户工作只会撤销进程本地续行权限。如果 pause 变更失败,驱动器会回退到停用续行。插件 teardown 会关闭准入,停用所有活跃 goal 的续行,以 `parent` cause 取消已经准入的 Round,并在事件隔离仍安装的情况下等待驱动器和 agent 完全停稳。
取消会移除 inbox 中待处理的工作,或留下 agent 范围的 aborted 状态。在下一次 idle 检查点,驱动器会暂停存在已预留或已准入尝试goal,避免取消后自动重启;与 goal 尝试无关的取消只会撤销进程本地续行权限。如果 pause 变更失败,驱动器会回退到停用续行。插件 teardown 会关闭准入,停用所有活跃 goal 的续行,以 `parent` cause 取消正在进行的工作,并在事件隔离仍安装的情况下等待驱动器和 agent 完全停稳。
## 模型体验
@@ -69,5 +60,5 @@
- **没有独立评估器**:面向模型的 goal 策略会判断证据是否足以完成,以及 blocker 在语义上是否未变;评估器支持的认证仍保持暂缓。
- **只在同一会话执行**:此包有意不 spawn 新 agent、不 fork 会话前缀,也不实现 Ralph 风格的独立尝试;该工作流属于单独的插件层。
- **已接受队列的卸载竞态**Cordis 插件卸载是异步的。已经被 agent inbox 接受的 goal 提示词可以在卸载开始前启动并消耗其 Roundteardown 随后会取消请求、撤销 goal 激活并等待完全停稳。不会再启动后续 Round。
- **只有 Round 上限,不是资源预算**token、货币、时间与提供方配额策略保持独立;观察到 `RATE_LIMIT``QUOTA` 时,只会映射为阻塞原因代码 `usage-limited`
- **只有 Round 上限,不是资源预算**token、货币、时间与提供方配额策略保持独立。对应的会话事件不会归属于 goal 消息,也不会映射为 goal 阻塞代码
- **异常情况不自动重试**:暂时性的提供方与持久化失败需要之后由用户授权 resume而不会采用隐式重试策略。

View File

@@ -6,24 +6,18 @@
import { isDeepStrictEqual } from 'node:util'
import { FiberState } from 'cordis'
import type { Context } from 'cordis'
import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
import { createUserMessage, assertNever } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
import { classifyGoalRound } from './outcome.ts'
import type { GoalRoundOutcome } from './outcome.ts'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session'
import { renderGoalRoundPrompt } from './prompt.ts'
export { classifyGoalRound } from './outcome.ts'
export type { GoalRoundOutcome } from './outcome.ts'
export { renderGoalRoundPrompt } from './prompt.ts'
export const name = 'goal-session'
export const inject = ['agents', 'goals', 'sessions']
const STALE_ROUND_REASON = 'stale goal-round reservation'
/** Identity reserved before a goal continuation enters the agent inbox. */
interface RoundIdentity {
readonly goalId: GoalRef['id']
@@ -31,12 +25,12 @@ interface RoundIdentity {
readonly round: number
}
/** One queued or admitted attempt, retained until its physical turn settles. */
/** One queued, claimed, or admitted goal message retained until whole-agent quiescence. */
interface RoundAttempt extends RoundIdentity {
readonly messageId: MessageId
readonly content: ContentBlock[]
phase: 'queued' | 'admitted'
turn: number | undefined
reason: TurnEndReason | undefined
phase: 'queued' | 'claimed' | 'admitted'
cancelled: boolean
stale: boolean
}
@@ -44,13 +38,11 @@ interface RoundAttempt extends RoundIdentity {
interface DriverState {
readonly agent: Agent
attempt: RoundAttempt | undefined
openTurn: number | undefined
competingQueued: boolean
needsCheckpoint: boolean
requested: boolean
run: Promise<void> | undefined
stopping: boolean
readonly flushFailedTurns: Set<number>
}
/** Whether a source identifies an automatic, positive-numbered goal round. */
@@ -91,13 +83,11 @@ export function apply(ctx: Context): void {
const state: DriverState = {
agent,
attempt: undefined,
openTurn: undefined,
competingQueued: false,
needsCheckpoint: false,
requested: false,
run: undefined,
stopping: false,
flushFailedTurns: new Set(),
}
states.set(agent, state)
return state
@@ -133,28 +123,18 @@ export function apply(ctx: Context): void {
}
}
/** Apply one closed-round outcome only to the exact still-current revision. */
function applyOutcome(state: DriverState, goal: GoalView, outcome: GoalRoundOutcome): void {
const ref = goalRef(goal)
switch (outcome.kind) {
case 'continue':
return
case 'pause':
ctx.goals.pause(state.agent, ref)
return
case 'blocked':
ctx.goals.block(state.agent, ref, { code: outcome.code, message: outcome.message })
return
case 'disarm':
ctx.goals.disarm(state.agent)
return
/* v8 ignore next 2 -- GoalRoundOutcome is closed and every member is handled above */
default:
assertNever(outcome, 'goal round outcome')
/** Preserve claimed step context when this driver drops only its own round. */
function restoreOtherClaimed(agent: Agent, messages: UserMessage[], messageId: MessageId): void {
const retained = messages.filter(message => message.id !== messageId
&& !(message.source.kind === 'goal' && message.source.round === 0))
for (const message of retained.toReversed()) {
if (agent.inbox.nextStep.some(candidate => candidate.id === message.id)
|| agent.inbox.nextTurn.some(candidate => candidate.id === message.id)) continue
agent.inbox.prepend('next-step', message)
}
}
/** Process a settled attempt, then reserve at most one next round. */
/** Process admitted work at quiescence, then reserve at most one next round. */
async function drive(state: DriverState): Promise<void> {
const { agent } = state
if (!readyToDrive(state)) return
@@ -165,8 +145,7 @@ export function apply(ctx: Context): void {
await ctx.sessions.flush(agent.session)
} catch (error: unknown) {
ctx.logger.warn(`goal-session: durability checkpoint failed for agent "${agent.id}": ${renderThrown(error)}`)
const goal = currentGoal(state)
if (goal !== undefined) applyOutcome(state, goal, { kind: 'disarm', reason: 'durability-failed' })
disarm(state)
return
}
// A mutation or ordinary prompt may have arrived while the checkpoint
@@ -176,27 +155,7 @@ export function apply(ctx: Context): void {
const attempt = state.attempt
if (attempt !== undefined) {
// Still unsettled: a contained turn-close failure reaches idle with the
// attempt's turn open in the log and no terminal reason recorded, so
// the drive pass must yield rather than misread it as settled.
if (attempt.reason === undefined) return
state.attempt = undefined
const turn = attempt.turn
/* v8 ignore next -- a closed attempt acquired its turn at turn/start */
if (turn === undefined) throw new Error('settled goal-round attempt lacks a turn')
const durable = !state.flushFailedTurns.delete(turn)
const goal = currentGoal(state)
if (goal !== undefined && goal.id === attempt.goalId && goal.revision === attempt.revision
&& goal.phase === 'active' && goal.activation === 'armed') {
const outcome = classifyGoalRound(attempt.reason, durable)
if (!attempt.stale) applyOutcome(state, goal, outcome)
}
if (!readyToDrive(state)) return
// The loop's persistence is eager write-behind with no turn-end flush,
// so this driver owns the round's durability barrier: checkpoint the
// settled round before reserving another (re-entering drive through
// the flush path above), disarming on failure instead of queueing an
// autonomous round on state that was never persisted.
state.needsCheckpoint = true
state.requested = true
return
@@ -214,19 +173,23 @@ export function apply(ctx: Context): void {
const round = goal.roundsStarted + 1
const content = renderGoalRoundPrompt(goal, round)
const message = createUserMessage({
content,
source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round },
})
const reservation: RoundAttempt = {
goalId: goal.id,
revision: goal.revision,
round,
messageId: message.id,
content,
phase: 'queued',
turn: undefined,
reason: undefined,
cancelled: false,
stale: false,
}
state.attempt = reservation
try {
agent.followup(createUserMessage({ content, source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round } }))
agent.followup(message)
} catch (error: unknown) {
state.attempt = undefined
ctx.logger.warn(`goal-session: could not queue round ${round} for agent "${agent.id}": ${renderThrown(error)}`)
@@ -243,7 +206,7 @@ export function apply(ctx: Context): void {
/** Coalesce triggers onto one agent-local serialized driver. */
function requestDrive(state: DriverState): void {
/* v8 ignore next -- teardown may race a final trigger after synchronously closing admission */
/* v8 ignore next -- teardown may race a final trigger after synchronously closing the step fence */
if (state.stopping) return
state.requested = true
if (state.run !== undefined) return
@@ -277,16 +240,11 @@ export function apply(ctx: Context): void {
})
}
// One composite effect owns every listener and the quiescent close. Cordis
// unloads sibling effects concurrently; nesting makes the close run first
// and keeps the admission fence installed until its drain settles.
// One composite effect keeps the step fence installed until this
// plugin's own scheduling tasks settle.
ctx.effect(function* () {
/** Mark a post-turn persistence failure before idle scheduling can run. */
ctx.on('agent/error', (agent, turn) => {
ctx.on('agent/error', (agent) => {
const state = stateFor(agent)
const closed = agent.session.events.some(event => event.type === 'turn/end' && event.data.turn === turn)
if (!closed) return
if (state.attempt?.turn === turn) state.flushFailedTurns.add(turn)
disarm(state)
})
@@ -295,96 +253,77 @@ export function apply(ctx: Context): void {
ctx.on('agent/session-start', (agent) => {
const state = stateFor(agent)
state.attempt = undefined
state.openTurn = undefined
state.competingQueued = false
state.needsCheckpoint = false
state.flushFailedTurns.clear()
})
ctx.on('agent/status', (agent, status) => {
const state = stateFor(agent)
if (status === 'idle') {
state.competingQueued = false
const attempt = state.attempt
const goal = currentGoal(state)
if ((attempt?.phase === 'queued' || attempt?.phase === 'claimed' || attempt?.cancelled)
&& goal?.phase === 'active' && goal.activation === 'armed') {
state.attempt = undefined
try {
ctx.goals.pause(agent, goalRef(goal))
} catch (error: unknown) {
ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`)
disarm(state)
}
}
requestDrive(state)
}
})
ctx.on('agent/inbox/enqueue', (agent, item) => {
const state = stateFor(agent)
const attempt = state.attempt
if (attempt !== undefined && sameQueued(item.message.content, item.message.source, attempt)) return
state.competingQueued = true
if (attempt?.phase === 'queued') attempt.stale = true
})
ctx.on('agent/cancel-requested', (agent, cause) => {
const state = stateFor(agent)
const attempt = state.attempt
state.competingQueued = false
const goal = currentGoal(state)
if (goal?.phase === 'active' && goal.activation === 'armed') {
if (attempt === undefined) {
disarm(state)
return
}
// An admitted round closes durably as aborted; retain it so the normal
// turn outcome path appends pause after cancellation reaches idle.
// Pausing here would stage context into the active outbox only for this
// same cancel() call to discard it.
if (attempt.turn !== undefined || attempt.phase === 'admitted') return
state.attempt = undefined
try {
applyOutcome(state, goal, { kind: 'pause', reason: cause.kind })
} catch (error: unknown) {
ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`)
disarm(state)
}
}
})
ctx.on('goal/changed', (agent) => {
const state = stateFor(agent)
state.needsCheckpoint = true
requestDrive(state)
})
ctx.on('agent/inbox/inserted', (agent, { message }) => {
if (!agent.inbox.nextTurn.some(candidate => candidate.id === message.id)) return
const state = stateFor(agent)
const attempt = state.attempt
if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) return
state.competingQueued = true
if (attempt?.phase === 'queued') attempt.stale = true
})
ctx.on('agent/inbox/claimed', (agent, { message }) => {
const state = stateFor(agent)
const attempt = state.attempt
if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) {
attempt.phase = 'claimed'
}
})
ctx.on('agent/inbox/discarded', (agent, { message }) => {
const state = stateFor(agent)
const attempt = state.attempt
if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) {
attempt.cancelled = true
}
})
ctx.on('session/event', (session: Session, event: SessionEvent) => {
const agent = ctx.agents.get(session.id)
if (agent === undefined || agent.session !== session) return
const state = stateFor(agent)
switch (event.type) {
case 'turn/start':
state.openTurn = event.data.turn
switch (event.data.trigger.kind) {
case 'message':
if (state.attempt !== undefined && isGoalRoundSource(event.data.trigger.source)
&& sameRound(event.data.trigger.source, state.attempt)) {
state.attempt.turn = event.data.turn
}
return
case 'retry':
// A recovery policy (llm-retry) closed the round's failed turn
// and reopened its history: the attempt rides the retry turn,
// and the failed turn's provisional reason no longer settles
// the round — the retry's own outcome does.
if (state.attempt !== undefined && state.attempt.reason !== undefined
&& state.attempt.reason.kind === 'error') {
state.attempt.turn = event.data.turn
state.attempt.reason = undefined
}
return
default:
// Injection and merge-extensible plugin triggers cannot admit a queued goal message.
return
}
case 'user/message':
if (state.attempt !== undefined && isGoalRoundSource(event.data.source)
&& sameRound(event.data.source, state.attempt)) {
if (state.attempt !== undefined && event.data.id === state.attempt.messageId) {
state.attempt.phase = 'admitted'
/* v8 ignore next -- this driver's admitted message always follows its observed turn/start */
if (state.openTurn !== undefined) state.attempt.turn = state.openTurn
}
return
case 'turn/end':
if (state.attempt?.turn === event.data.turn) state.attempt.reason = event.data.reason
/* v8 ignore next -- balanced live turns close the open turn just observed by this listener */
if (state.openTurn === event.data.turn) state.openTurn = undefined
if (event.data.reason.kind === 'max-tokens') {
disarm(state)
return
}
if (event.data.reason.kind !== 'aborted') return
if (state.attempt?.phase === 'claimed' || state.attempt?.phase === 'admitted') {
state.attempt.cancelled = true
}
else disarm(state)
return
default:
return
@@ -400,22 +339,24 @@ export function apply(ctx: Context): void {
const attempt = state.attempt
const goal = currentGoal(state)
return ctx.fiber.state === FiberState.ACTIVE
&& !state.stopping && attempt !== undefined && attempt.phase === 'queued'
&& !state.stopping && attempt !== undefined && attempt.phase === 'claimed'
&& !attempt.stale && sameQueued(content, source, attempt)
&& goal !== undefined && goal.id === source.goalId && goal.revision === source.revision
&& goal.phase === 'active' && goal.activation === 'armed'
&& source.round === goal.roundsStarted + 1
}
ctx.on('agent/prompt-submit', async (agent, message, _signal, next): Promise<PromptDecision> => {
const { content, source } = message
if (!isGoalRoundSource(source)) return next()
ctx.on('agent/pre-step', async (agent, messages, { signal }, next): Promise<PreStepDecision> => {
const submitted = messages.find((message): message is UserMessage & { source: GoalMessageSource } =>
isGoalRoundSource(message.source))
if (submitted === undefined) return next()
const { content, source } = submitted
const state = stateFor(agent)
let valid = false
try {
valid = validReservation(state, content, source)
} catch (error: unknown) {
ctx.logger.warn(`goal-session: admission check failed for agent "${agent.id}": ${renderThrown(error)}`)
ctx.logger.warn(`goal-session: pre-step check failed for agent "${agent.id}": ${renderThrown(error)}`)
disarm(state)
}
if (!valid) {
@@ -424,33 +365,34 @@ export function apply(ctx: Context): void {
attempt.stale = true
state.attempt = undefined
}
restoreOtherClaimed(agent, messages, submitted.id)
requestDrive(state)
return { kind: 'block', reason: STALE_ROUND_REASON }
return { kind: 'reject' }
}
let decision: PromptDecision
let decision: PreStepDecision
try {
decision = await next()
} catch (error: unknown) {
// A throwing downstream hook drops the whole admission: the loop
// returns to idle without a turn, so a still-queued reservation would
// starve every later drive pass. Clear it and let the driver
// reschedule the round.
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt) && attempt.turn === undefined) {
state.attempt = undefined
requestDrive(state)
}
if (signal.aborted) throw error
// A throwing downstream hook drops the whole step proposal. Clear the
// reservation before the balanced no-step turn returns to idle so the
// next drive pass can reschedule the round.
state.attempt = undefined
requestDrive(state)
throw error
}
if (decision.kind === 'block') {
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt)) state.attempt = undefined
if (signal.aborted) {
if (decision.kind === 'enter') restoreOtherClaimed(agent, decision.messages, submitted.id)
return decision
}
if (decision.kind === 'reject') {
state.attempt = undefined
const goal = currentGoal(state)
if (goal !== undefined && goal.id === source.goalId && goal.revision === source.revision
&& goal.phase === 'active' && goal.activation === 'armed') {
ctx.goals.block(agent, goalRef(goal), {
code: 'prompt-rejected',
message: decision.reason,
message: 'Goal round was rejected before entering its step.',
})
}
return decision
@@ -458,18 +400,15 @@ export function apply(ctx: Context): void {
try {
valid = validReservation(state, content, source)
} catch (error: unknown) {
ctx.logger.warn(`goal-session: post-admission check failed for agent "${agent.id}": ${renderThrown(error)}`)
ctx.logger.warn(`goal-session: post-decision check failed for agent "${agent.id}": ${renderThrown(error)}`)
disarm(state)
valid = false
}
if (!valid) {
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt)) {
attempt.stale = true
state.attempt = undefined
}
state.attempt = undefined
restoreOtherClaimed(agent, decision.messages, submitted.id)
requestDrive(state)
return { kind: 'block', reason: STALE_ROUND_REASON }
return { kind: 'reject' }
}
return decision
})
@@ -491,10 +430,11 @@ export function apply(ctx: Context): void {
const attempt = state.attempt
if (attempt !== undefined) {
attempt.stale = true
if (attempt.phase === 'admitted' && state.agent.status === 'running') {
/* v8 ignore next -- followup reserves the live agent before publishing a queued attempt */
if (state.agent.status === 'running') {
state.agent.cancel({ kind: 'parent' })
waits.push(state.agent.whenIdle())
}
waits.push(state.agent.whenIdle())
}
if (state.run !== undefined) waits.push(state.run)
}

View File

@@ -1,51 +0,0 @@
/** Typed settlement policy for one admitted same-session goal round. */
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
/** Driver action derived from one closed goal-owned turn. */
export type GoalRoundOutcome =
| { readonly kind: 'continue' }
| { readonly kind: 'pause'; readonly reason: string }
| {
readonly kind: 'blocked'
readonly code: 'usage-limited' | 'turn-error' | 'max-tokens' | 'unknown-turn-outcome'
readonly message: string
}
| { readonly kind: 'disarm'; readonly reason: 'durability-failed' | 'disposed' | 'interrupted' }
/**
* Classify one closed goal round without mutating goal state.
* @param reason - durable reason from the round's `turn/end`.
* @param durable - whether the closing flush reached its durability checkpoint.
* @returns the single driver action; no abnormal outcome requests an automatic retry.
*/
export function classifyGoalRound(reason: TurnEndReason, durable: boolean): GoalRoundOutcome {
if (!durable) return { kind: 'disarm', reason: 'durability-failed' }
const extensibleReason: { readonly kind: string } = reason
switch (reason.kind) {
case 'completed':
return { kind: 'continue' }
case 'aborted':
return { kind: 'pause', reason: 'cancelled' }
case 'error': {
const { code, message } = reason.failure ?? reason
return code === 'RATE_LIMIT' || code === 'QUOTA'
? { kind: 'blocked', code: 'usage-limited', message }
: { kind: 'blocked', code: 'turn-error', message }
}
case 'max-tokens':
return { kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' }
case 'disposed':
return { kind: 'disarm', reason: 'disposed' }
case 'interrupted':
return { kind: 'disarm', reason: 'interrupted' }
// TurnEndReason is merge-extensible. An unknown producer cannot opt into
// automatic retry merely by adding a tag; stop for inspection instead.
default:
return {
kind: 'blocked',
code: 'unknown-turn-outcome',
message: `unknown turn outcome: ${extensibleReason.kind}`,
}
}
}

View File

@@ -1,24 +1,17 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import GoalService, { foldGoal, GoalId } from '@deepseek-ai/dsh-goal'
import GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
import type { GoalView } from '@deepseek-ai/dsh-goal'
import { createUserMessage, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import * as goalSession from '../src/index.ts'
declare module '@deepseek-ai/dsh-session' {
interface TurnTriggerMap {
/** Test-only plugin turn with no message source. */
'test-metadata': { kind: 'test-metadata' }
}
}
type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[])
/** Small request-recording adapter with controllable failure and cancellation. */
@@ -108,6 +101,28 @@ async function harness(script: ScriptEntry[]): Promise<Harness> {
return { ctx, adapter, agent, driver }
}
/** Observe inserted inbox messages after the live projection accepts them. */
function onInboxMessage(
ctx: Context,
agent: Agent,
listener: (message: UserMessage) => void,
): () => void {
return ctx.on('agent/inbox/inserted', (subject, { message }) => {
if (subject === agent) listener(message)
})
}
/** Observe one claimed message at its exclusive pre-step ownership transfer. */
function onClaimedMessage(
ctx: Context,
agent: Agent,
listener: (message: UserMessage) => void,
): () => void {
return ctx.on('agent/inbox/claimed', (subject, { message }) => {
if (subject === agent) listener(message)
})
}
/** Await a stable goal projection selected by the caller. */
async function waitForGoal(
ctx: Context,
@@ -128,28 +143,6 @@ async function waitForRequests(adapter: ScriptedAdapter, count: number): Promise
}
describe('goal-round outcome policy', () => {
it.each([
[{ kind: 'completed' }, true, { kind: 'continue' }],
[{ kind: 'aborted' }, true, { kind: 'pause', reason: 'cancelled' }],
[{ kind: 'error', step: 1, message: 'slow down', code: 'RATE_LIMIT' }, true,
{ kind: 'blocked', code: 'usage-limited', message: 'slow down' }],
[{ kind: 'error', step: 1, failure: { message: 'credits exhausted', code: 'QUOTA' } }, true,
{ kind: 'blocked', code: 'usage-limited', message: 'credits exhausted' }],
[{ kind: 'error', step: 1, failure: { message: 'provider failed', code: 'SERVER' } }, true,
{ kind: 'blocked', code: 'turn-error', message: 'provider failed' }],
[{ kind: 'error', step: 1, message: 'broken' }, true,
{ kind: 'blocked', code: 'turn-error', message: 'broken' }],
[{ kind: 'max-tokens' }, true,
{ kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' }],
[{ kind: 'disposed' }, true, { kind: 'disarm', reason: 'disposed' }],
[{ kind: 'interrupted' }, true, { kind: 'disarm', reason: 'interrupted' }],
[{ kind: 'completed' }, false, { kind: 'disarm', reason: 'durability-failed' }],
[{ kind: 'future-outcome' } as unknown as TurnEndReason, true,
{ kind: 'blocked', code: 'unknown-turn-outcome', message: 'unknown turn outcome: future-outcome' }],
] as const)('maps %j without abnormal automatic retry', (reason, durable, expected) => {
expect(goalSession.classifyGoalRound(reason, durable)).toEqual(expected)
})
it('renders the objective, round budget, authority boundary, and completion protocol', () => {
const goal: GoalView = {
id: GoalId('goal-prompt'),
@@ -238,39 +231,42 @@ describe('same-session goal driving', () => {
})
it.each([
['rate limit', new LlmError('slow down', 'RATE_LIMIT'), 'usage-limited'],
['request error', new Error('provider broke'), 'turn-error'],
['max tokens', maxTokensResponse('unfinished'), 'max-tokens'],
] as const)('stops after a %s without an automatic retry', async (_label, response, code) => {
['rate limit', new LlmError('slow down', 'RATE_LIMIT')],
['request error', new Error('provider broke')],
['max tokens', maxTokensResponse('unfinished')],
] as const)('disarms automatic continuation after a %s', async (_label, response) => {
const test = await harness([response])
test.ctx.goals.create(test.agent, { objective: 'stop safely', maxGoalRounds: 8 })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
const goal = await waitForGoal(test.ctx, test.agent, current =>
current?.phase === 'active' && current.activation === 'disarmed')
expect(goal).toMatchObject({ roundsStarted: 1, activation: 'disarmed' })
expect(goal?.blockedReason?.code).toBe(code)
expect(test.adapter.requests).toHaveLength(1)
})
it('maps a downstream prompt veto to blocked without admitting the round', async () => {
it('maps a downstream step rejection to blocked without entering the round', async () => {
const test = await harness([])
test.ctx.on('agent/prompt-submit', (_agent, message, _signal, next) => message.source.kind === 'goal'
? Promise.resolve({ kind: 'block', reason: 'deployment policy' })
test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal'
? Promise.resolve({ kind: 'reject' as const })
: next())
test.ctx.goals.create(test.agent, { objective: 'respect policy' })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
expect(goal?.roundsStarted).toBe(0)
expect(goal?.blockedReason).toEqual({ code: 'prompt-rejected', message: 'deployment policy' })
expect(goal?.blockedReason).toEqual({
code: 'prompt-rejected',
message: 'Goal round was rejected before entering its step.',
})
expect(test.adapter.requests).toHaveLength(0)
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true)
})
it('does not reserve again when a stopped-goal observer queues ordinary work', async () => {
it('does not reserve again when a stopped-goal observer queues cancel-scoped work', async () => {
const test = await harness([textResponse('human follow-up')])
test.ctx.on('agent/prompt-submit', (_agent, message, _signal, next) => message.source.kind === 'goal'
? Promise.resolve({ kind: 'block', reason: 'stop this round' })
test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal'
? Promise.resolve({ kind: 'reject' as const })
: next())
test.ctx.on('goal/changed', (agent, change) => {
if (change.operation === 'block') agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the blocker' }], source: { kind: 'user' } }))
@@ -278,18 +274,19 @@ describe('same-session goal driving', () => {
test.ctx.goals.create(test.agent, { objective: 'stop and inspect' })
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
await waitForRequests(test.adapter, 1)
await test.agent.whenIdle()
expect(requestText(test.adapter.requests[0]!)).toContain('inspect the blocker')
expect(test.adapter.requests).toHaveLength(0)
expect(test.agent.inbox.nextTurn.map(message => message.content[0]))
.toEqual([{ type: 'text', text: 'inspect the blocker' }])
})
it('pauses and drops a reserved round when cancellation lands before admission', async () => {
it('pauses and drops a reserved round when cancellation lands before pre-step', async () => {
const test = await harness([])
const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent === test.agent && info.message.source.kind === 'goal') {
const cancel = onClaimedMessage(test.ctx, test.agent, (message) => {
if (message.source.kind === 'goal' && message.source.round > 0) {
cancel()
agent.cancel({ kind: 'user' })
test.agent.cancel({ kind: 'user' })
}
})
test.ctx.goals.create(test.agent, { objective: 'do not start yet' })
@@ -298,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)
})
@@ -314,10 +311,6 @@ describe('same-session goal driving', () => {
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused')
expect(goal).toMatchObject({ roundsStarted: 1, activation: 'disarmed' })
expect(foldGoal(test.agent.session.events)).toMatchObject({
goal: { phase: 'paused', revision: 2 },
roundsStarted: 1,
})
expect(test.adapter.requests).toHaveLength(1)
})
@@ -334,38 +327,13 @@ describe('same-session goal driving', () => {
expect(requestText(test.adapter.requests[1]!)).toContain('<goal_round>')
})
it('ignores plugin-owned turn triggers while a goal round is queued', async () => {
const test = await harness([textResponse('goal answer')])
const warnings: string[] = []
test.ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof test.ctx.logger.warn
let inserted = false
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.message.source.kind !== 'goal' || inserted) return
inserted = true
const lastStart = agent.session.events.findLast(event => event.type === 'turn/start')
const turn = (lastStart?.data.turn ?? 0) + 1
agent.session.append('turn/start', {
turn,
trigger: { kind: 'test-metadata' },
})
agent.session.append('turn/end', { turn, reason: { kind: 'completed' } })
})
test.ctx.goals.create(test.agent, { objective: 'ignore metadata', maxGoalRounds: 1 })
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
expect(inserted).toBe(true)
expect(test.adapter.requests).toHaveLength(1)
expect(warnings.some(warning => warning.includes('session/event listener threw'))).toBe(false)
})
it('makes a reserved round stale when a listener queues human work behind it', async () => {
const test = await harness([textResponse('human batch'), textResponse('later goal')])
let inserted = false
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.message.source.kind !== 'goal' || inserted) return
onInboxMessage(test.ctx, test.agent, (message) => {
if (message.source.kind !== 'goal' || inserted) return
inserted = true
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human joined the pending batch' }], source: { kind: 'user' } }))
test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human joined the pending batch' }], source: { kind: 'user' } }))
})
test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 })
@@ -380,12 +348,12 @@ describe('same-session goal driving', () => {
it('blocks a queued reservation made stale by a goal edit and continues the new revision', async () => {
const test = await harness([textResponse('new revision')])
let edited = false
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.message.source.kind !== 'goal' || edited) return
onInboxMessage(test.ctx, test.agent, (message) => {
if (message.source.kind !== 'goal' || edited) return
edited = true
const current = test.ctx.goals.get(agent)
const current = test.ctx.goals.get(test.agent)
if (current === undefined) throw new Error('missing goal during queued edit')
test.ctx.goals.edit(agent, current, { objective: 'new objective' })
test.ctx.goals.edit(test.agent, current, { objective: 'new objective' })
})
test.ctx.goals.create(test.agent, { objective: 'old objective', maxGoalRounds: 1 })
@@ -402,8 +370,8 @@ describe('same-session goal driving', () => {
it('rechecks revision after downstream prompt hooks before admitting', async () => {
const test = await harness([textResponse('new revision')])
let edited = false
test.ctx.on('agent/prompt-submit', (agent, message, _signal, next) => {
if (message.source.kind === 'goal' && !edited) {
test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => {
if (messages[0]?.source.kind === 'goal' && !edited) {
edited = true
const current = test.ctx.goals.get(agent)
if (current === undefined) throw new Error('missing goal during prompt edit')
@@ -411,7 +379,7 @@ describe('same-session goal driving', () => {
}
return next()
})
test.ctx.goals.create(test.agent, { objective: 'edit during admission', maxGoalRounds: 1 })
test.ctx.goals.create(test.agent, { objective: 'edit during pre-step', maxGoalRounds: 1 })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
@@ -419,6 +387,81 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(1)
})
it('does not block a goal that downstream paused before rejecting its prompt', async () => {
const test = await harness([])
test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => {
if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0)) {
return next()
}
const goal = test.ctx.goals.get(agent)
if (goal === undefined) throw new Error('missing goal before downstream pause')
test.ctx.goals.pause(agent, { id: goal.id, revision: goal.revision })
return { kind: 'reject' as const }
})
test.ctx.goals.create(test.agent, { objective: 'pause before rejection' })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused')
expect(goal).toMatchObject({ phase: 'paused' })
expect(test.adapter.requests).toEqual([])
})
it('restores non-goal step context when a claimed reservation becomes stale', async () => {
const test = await harness([textResponse('side contexts'), textResponse('revised goal')])
const claimedContext = createUserMessage({
content: [{ type: 'text', text: 'claimed context to restore' }],
source: { kind: 'plugin', plugin: 'test' },
})
const roundZeroContext = createUserMessage({
content: [{ type: 'text', text: 'obsolete goal context' }],
source: { kind: 'goal', goalId: GoalId('old-goal'), revision: 1, round: 0 },
})
const queuedStepContext = createUserMessage({
content: [{ type: 'text', text: 'context already queued for the next step' }],
source: { kind: 'plugin', plugin: 'test' },
})
const queuedTurnContext = createUserMessage({
content: [{ type: 'text', text: 'context already queued for the next turn' }],
source: { kind: 'plugin', plugin: 'test' },
})
let staged = false
const stopInserted = onInboxMessage(test.ctx, test.agent, (message) => {
if (message.source.kind !== 'goal' || message.source.round <= 0 || staged) return
staged = true
test.agent.inbox.prepend('next-step', claimedContext)
test.agent.inbox.prepend('next-step', roundZeroContext)
})
let edited = false
test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => {
const decision = await next()
if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0) || edited) return decision
edited = true
agent.inbox.prepend('next-step', queuedStepContext)
agent.inbox.append('next-turn', queuedTurnContext)
const goal = test.ctx.goals.get(agent)
if (goal === undefined) throw new Error('missing claimed goal')
test.ctx.goals.edit(agent, goal, { objective: 'revised after claim' })
return decision.kind === 'reject' ? decision : {
kind: 'enter' as const,
messages: [...decision.messages, queuedStepContext, queuedTurnContext],
}
})
test.ctx.goals.create(test.agent, { objective: 'stale before admission', maxGoalRounds: 1 })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
stopInserted()
expect(goal).toMatchObject({ objective: 'revised after claim', roundsStarted: 1 })
expect(test.adapter.requests).toHaveLength(2)
expect(requestText(test.adapter.requests[0]!)).toContain('claimed context to restore')
expect(requestText(test.adapter.requests[0]!)).toContain('context already queued for the next step')
expect(requestText(test.adapter.requests[0]!)).toContain('context already queued for the next turn')
expect(requestText(test.adapter.requests[0]!)).not.toContain('obsolete goal context')
expect(requestText(test.adapter.requests[0]!)).not.toContain('<goal_round>')
expect(requestText(test.adapter.requests[1]!)).toContain('revised after claim')
expect(requestText(test.adapter.requests[1]!)).not.toContain('stale before admission')
})
it('disarms without dispatch when a durability checkpoint fails', async () => {
const test = await harness([])
test.ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable')))
@@ -509,8 +552,8 @@ describe('same-session goal driving', () => {
// attempt through cancel-requested) and THEN throws: the catch finds no
// matching reservation and must not reschedule a paused goal.
let fired = false
test.ctx.on('agent/prompt-submit', async (agent, message, _signal, next) => {
if (message.source.kind === 'goal' && !fired) {
test.ctx.on('agent/pre-step', async (agent, messages, _signal, next) => {
if (messages[0]?.source.kind === 'goal' && !fired) {
fired = true
agent.cancel({ kind: 'user' })
throw new Error('hook cancelled then exploded')
@@ -528,26 +571,24 @@ describe('same-session goal driving', () => {
expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'paused' })
})
it('reschedules the round when a downstream admission hook throws', async () => {
const test = await harness([textResponse('second admission succeeded')])
it('fails closed when a downstream pre-step hook throws', async () => {
const test = await harness([])
// Registered after goal-session's own listener: the throw propagates back
// through goal-session's next() await, dropping the whole admission.
// through goal-session's next() await, dropping the whole step proposal.
let threw = false
test.ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => {
if (message.source.kind === 'goal' && !threw) {
test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
if (messages[0]?.source.kind === 'goal' && !threw) {
threw = true
throw new Error('downstream admission hook exploded')
throw new Error('downstream pre-step hook exploded')
}
return next()
})
test.ctx.goals.create(test.agent, { objective: 'survive a throwing hook', maxGoalRounds: 1 })
// The cleared reservation lets the driver reschedule; the second
// admission passes and the round completes to its limit.
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
expect(goal?.blockedReason?.code).toBe('round-limit')
expect(goal?.roundsStarted).toBe(1)
expect(test.adapter.requests).toHaveLength(1)
const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed')
expect(goal).toMatchObject({ phase: 'active', roundsStarted: 0 })
expect(test.adapter.requests).toHaveLength(0)
expect(test.agent.inbox.nextTurn).toHaveLength(0)
})
it('a retry turn on a non-goal failure leaves the goal reservation untouched', async () => {
@@ -657,20 +698,20 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(0)
})
it('fails a pre-admission read closed even when the first disarm attempt throws', async () => {
it('fails an initial pre-step read closed even when the first disarm attempt throws', async () => {
const test = await harness([textResponse('retry after containment')])
let armed = true
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.message.source.kind !== 'goal' || !armed) return
onClaimedMessage(test.ctx, test.agent, (message) => {
if (message.source.kind !== 'goal' || message.source.round <= 0 || !armed) return
armed = false
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
throw new Error('admission projection failed')
throw new Error('pre-step projection failed')
})
vi.spyOn(test.ctx.goals, 'disarm').mockImplementationOnce(() => {
throw 'disarm failed'
})
})
test.ctx.goals.create(test.agent, { objective: 'retry stale admission', maxGoalRounds: 1 })
test.ctx.goals.create(test.agent, { objective: 'retry stale pre-step', maxGoalRounds: 1 })
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
@@ -680,8 +721,8 @@ describe('same-session goal driving', () => {
it('fails a post-hook read closed before the prompt can enter history', async () => {
const test = await harness([])
let armed = true
test.ctx.on('agent/prompt-submit', (_agent, message, _signal, next) => {
if (message.source.kind === 'goal' && armed) {
test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => {
if (messages[0]?.source.kind === 'goal' && armed) {
armed = false
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
throw new Error('post-hook projection failed')
@@ -703,7 +744,20 @@ describe('same-session goal driving', () => {
await test.agent.whenIdle()
expect(test.adapter.requests).toHaveLength(0)
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true)
})
it('leaves round-zero goal context to the ordinary pre-step chain', async () => {
const test = await harness([textResponse('accepted context')])
test.agent.followup(createUserMessage({
content: [{ type: 'text', text: 'goal context' }],
source: { kind: 'goal', goalId: GoalId('context-goal'), revision: 1, round: 0 },
}))
await test.agent.whenIdle()
expect(test.adapter.requests).toHaveLength(1)
expect(requestText(test.adapter.requests[0]!)).toContain('goal context')
})
it('does not invent goal state when ordinary queued work is cancelled', async () => {
@@ -736,13 +790,13 @@ describe('same-session goal driving', () => {
it('falls back to disarming when a cancelled reservation cannot be paused', async () => {
const test = await harness([])
const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.message.source.kind !== 'goal') return
const cancel = onInboxMessage(test.ctx, test.agent, (message) => {
if (message.source.kind !== 'goal' || message.source.round <= 0) return
cancel()
vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => {
throw new Error('pause failed')
})
agent.cancel({ kind: 'user' })
test.agent.cancel({ kind: 'user' })
})
test.ctx.goals.create(test.agent, { objective: 'fail closed after cancellation' })
@@ -752,17 +806,17 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(0)
})
it('blocks admission when downstream cancellation clears the reservation', async () => {
it('rejects the step when downstream cancellation clears the reservation', async () => {
const test = await harness([])
let cancelled = false
test.ctx.on('agent/prompt-submit', (agent, message, _signal, next) => {
if (message.source.kind === 'goal' && !cancelled) {
test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => {
if (messages[0]?.source.kind === 'goal' && !cancelled) {
cancelled = true
agent.cancel({ kind: 'user' })
}
return next()
})
test.ctx.goals.create(test.agent, { objective: 'cancel during admission' })
test.ctx.goals.create(test.agent, { objective: 'cancel during pre-step' })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused')
await test.agent.whenIdle()
@@ -783,15 +837,15 @@ describe('same-session goal driving', () => {
activation: 'disarmed',
roundsStarted: 1,
})
await test.agent.whenIdle()
expect(test.agent.status).toBe('idle')
expect(test.adapter.requests).toHaveLength(1)
})
it('cancels an accepted queued round and awaits its driver task during teardown', async () => {
const test = await harness([])
let unloading: Promise<void> | undefined
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent === test.agent && info.message.source.kind === 'goal' && unloading === undefined) {
onInboxMessage(test.ctx, test.agent, (message) => {
if (message.source.kind === 'goal' && unloading === undefined) {
unloading = Promise.resolve(test.driver.dispose())
}
})
@@ -821,34 +875,8 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(1)
})
it('leaves a queued reservation pending when the driver runs before its turn settles', async () => {
const test = await harness([textResponse('settled later')])
let woken = false
test.ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => {
if (message.source.kind === 'goal' && !woken) {
woken = true
// A concurrent driver pass must observe the still-unsettled attempt
// and yield rather than double-book or clear the reservation.
agentEvents(test.ctx, test.agent).emit('agent/status', 'idle')
await new Promise<void>((resolve) => { setImmediate(resolve) })
}
return next()
})
test.ctx.goals.create(test.agent, { objective: 'wake early', maxGoalRounds: 1 })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
expect(goal?.blockedReason?.code).toBe('round-limit')
expect(goal?.roundsStarted).toBe(1)
expect(test.adapter.requests).toHaveLength(1)
})
it('yields to a round whose turn/end never committed instead of misreading it as settled', async () => {
it('disarms when a round turn/end cannot commit', async () => {
const test = await harness([textResponse('round ran')])
// A persistent pre-commit turn/end rejection: the loop contains the close
// failure and reaches idle, but the round's attempt holds a turn with no
// terminal reason. The idle drive pass must yield to that unsettled
// attempt rather than classify an absent reason or crash into disarm.
test.ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as { type: string }
@@ -859,12 +887,10 @@ describe('same-session goal driving', () => {
await test.agent.whenIdle()
await new Promise((resolve) => { setImmediate(resolve) })
// One request ran; the unsettled attempt parked the driver without a
// second reservation and without disarming the goal.
expect(test.adapter.requests).toHaveLength(1)
expect(test.ctx.goals.get(test.agent)).toMatchObject({
phase: 'active',
activation: 'armed',
activation: 'disarmed',
})
})
@@ -903,26 +929,32 @@ describe('same-session goal driving', () => {
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('goal-session'))
})
it('ignores the failed outcome of a round made stale by human work queued at turn start', async () => {
it('keeps terminal agent failure disarmed and defers queued human work until another wakeup', async () => {
const test = await harness([new Error('round one broke'), textResponse('human answer')])
let queued = false
test.ctx.on('session/event', (session, event) => {
if (session !== test.agent.session || queued) return
if (event.type === 'turn/start' && event.data.trigger.kind === 'message'
&& event.data.trigger.source.kind === 'goal') {
if (event.type === 'user/message' && event.data.source.kind === 'goal') {
queued = true
test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human interleaved' }], source: { kind: 'user' } }))
queueMicrotask(() => {
test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human interleaved' }], source: { kind: 'user' } }))
})
}
})
test.ctx.goals.create(test.agent, { objective: 'survive a stale failure', maxGoalRounds: 1 })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
await waitForGoal(test.ctx, test.agent, current =>
current?.phase === 'active' && current.activation === 'disarmed')
expect(test.adapter.requests).toHaveLength(1)
expect(test.agent.inbox.nextTurn).toHaveLength(1)
test.agent.steer(createUserMessage({ content: [{ type: 'text', text: 'resume after failure' }], source: { kind: 'user' } }))
await test.agent.whenIdle()
// The stale round's turn-error never blocks the goal; only the durable
// round budget does, after the interleaved human turn ran.
expect(goal?.blockedReason?.code).toBe('round-limit')
expect(test.adapter.requests).toHaveLength(2)
expect(requestText(test.adapter.requests[1]!)).toContain('human interleaved')
expect(requestText(test.adapter.requests[1]!)).toContain('resume after failure')
})
it('waits for work queued by a pause observer before considering the next round', async () => {
@@ -950,11 +982,13 @@ describe('same-session goal driving', () => {
it('does not re-block a goal the downstream veto already saw cancelled', async () => {
const test = await harness([])
let vetoed = false
test.ctx.on('agent/prompt-submit', (agent, message, _signal, next) => {
if (message.source.kind === 'goal' && !vetoed) {
test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => {
if (messages[0]?.source.kind === 'goal' && !vetoed) {
vetoed = true
agent.cancel({ kind: 'user' })
return Promise.resolve<PromptDecision>({ kind: 'block', reason: 'cancelled by policy' })
return Promise.resolve<PreStepDecision>({
kind: 'reject',
})
}
return next()
})
@@ -970,16 +1004,16 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(0)
})
it('awaits an unadmitted reservation stuck in admission during teardown without cancelling', async () => {
it('awaits a claimed reservation stuck in pre-step during teardown without cancelling', async () => {
const test = await harness([])
let release: (() => void) | undefined
test.ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => {
if (message.source.kind === 'goal' && release === undefined) {
test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
if (messages[0]?.source.kind === 'goal' && release === undefined) {
await new Promise<void>((resolve) => { release = resolve })
}
return next()
})
test.ctx.goals.create(test.agent, { objective: 'unload during admission' })
test.ctx.goals.create(test.agent, { objective: 'unload during pre-step' })
await vi.waitFor(() => { expect(release).toBeDefined() })
const disposal = Promise.resolve(test.driver.dispose())
@@ -989,7 +1023,7 @@ describe('same-session goal driving', () => {
expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', roundsStarted: 0 })
expect(test.adapter.requests).toHaveLength(0)
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true)
})
it('ignores session events without an exact owning agent and retires disposed agent state', async () => {
@@ -997,7 +1031,6 @@ describe('same-session goal driving', () => {
const orphan = test.ctx.sessions.create(SessionId('goal-session-orphan'))
orphan.append('turn/start', {
turn: 1,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } },
})
orphan.append('turn/end', { turn: 1, reason: { kind: 'completed' } })

View File

@@ -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,30 +27,17 @@ 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 {
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
session.append('user/message', createUserMessage({
content: renderGoalChange(change),
source: changeSource,
}), { 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 {
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: turn - 1 } as const
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content, source,
}), { surfaceOp: 'append' })
@@ -82,15 +68,17 @@ describe('goal-session prompt invariants', () => {
ctx.sessions.create(SessionId('goal-session-invariant-dispatch'))
const userSource = { kind: 'user' } as const
session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: userSource } })
session.append('turn/start', { turn: 4 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'ordinary human message' }],
source: userSource,
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 4, reason: { kind: 'completed' } })
const stateSource = { ...changeSource, round: 0 } as const
session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: stateSource } })
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({
content: [{ type: 'text', text: 'round zero is not a driver continuation' }],
@@ -114,7 +102,7 @@ describe('goal-session prompt invariants', () => {
it('rejects a goal round without a reconstructable active goal', async () => {
const { session } = await mount()
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 } as const
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } })
session.append('turn/start', { turn: 1 })
expect(() => {
session.append('user/message', createUserMessage({
@@ -128,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, trigger: { kind: 'injection', source: changeSource } })
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 })

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/goal/goal/README.md
README.md: 45d7ac2e1c8fbd60ab2bc8b917b326d20da02ec1
README.zh.md: f0267ad82c3a686c61ef040fb1f3ec27a449e3ab
README.md: fc2a672c11c68ad72437251b087a274e1c4388d3
README.zh.md: eaaae5b333151b1d936effc593b21cac515471e2

View File

@@ -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 non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The model-visible `user/message` content and its typed `{ kind: 'goal', change }` source must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
Every mutation 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.
Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained.
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, 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 is one raw user-role context block. A snapshot is rendered as `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. The descriptive XML delimiter follows this repository's existing `<workspace_context>` 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. Tools such as `get_goal` 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
Every retained mutation adds one full snapshot to derived history until compaction shadows it. Full snapshots make each 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: each 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: each mutation follows the reusable request prefix a
- **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.

View File

@@ -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 且已启用续行的目标会拒绝冗余操作。
每次非 clear 变更都会通过 `agent.inject()` 追加完整的版本化快照clear 则追加带 revision 的 tombstone。模型可见的 `user/message` 内容与其带类型的 `{ kind: 'goal', change }` 来源必须完全一致。回放会拒绝形状错误、来源/内容漂移、不连续 revision、非法生命周期转换、每目标时间戳非单调以及不连续的 Goal Round。挂钟时间倒退时变更时间戳会限制在不早于上一次目标更新的值
每次变更都会追加持久的 `goal/change` 事件,其中携带变更后的完整快照clear 使用带 revision 的 tombstone。因此goal 状态不依赖 inbox 放置、领取、准入或丢弃。会话日志是唯一的持久权威
注入可以立即追加,也可能在活跃工具批次 FIFO 中等待。服务会在内存中叠加已接受的待处理变更,并在每个完全一致的载荷进入日志时逐一完成对账,因此连续的模型工具变更可以看到自身最新 revision而不会把尚未记录的缓存当作持久状态。可重入追加观察者会且只会看到每项已接受变更一次增量回放会把游标保留在第一个损坏事件处。追加或入队成功后才触发 `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 来源变更、模型可见内容漂移、不连续 revision、非法生命周期转换、时间戳回退以及不连续的已准入 round。
单独发布的 `./invariant` 配套模块会为每个已挂接会话维护独立折叠。它会在候选事件进入持久日志前拒绝格式错误的 goal 变更、不连续 revision、非法生命周期转换、时间戳回退以及不连续的已准入 Round。
## 扩展点
@@ -39,15 +39,15 @@
#### 模型看到的内容
每项变更都是一个原始用户角色上下文块。快照渲染为 `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`clear 会渲染 tombstone idrevision 与 `clearedAt`。日志外不存在隐藏状态摘要。这种描述性 XML 分隔符遵循仓库已有的 `<workspace_context>` 约定和 [Anthropic 发布的 XML 标签提示词指南](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags);它是公开的模型体验先例,并非关于任何提供方专有训练语料的声明
Goal 变更不会注入模型上下文。`get_goal` 等工具返回当前状态;继续执行消费方可以在调度模型工作时渲染目标描述与 Round 状态。未来如果需要始终可见的 goal 上下文,应由独立上下文插件实现,而不是放在持久化路径中
#### Token 影响
每项保留的变更都会向派生历史增加一份完整快照直到压缩compaction将其遮蔽。完整快照让每条记录都能独立检查但会重复目标和生命周期字段
Goal 变更事件本身不增加模型 token。工具结果与已调度的继续执行提示词分别计入其自身暴露的状态
#### KV Cache 影响
一个 epoch 内仅追加:每项变更都位于可复用请求前缀和既有历史之后。压缩可能替换派生历史后缀,并移动可复用边界
其他组件把 goal 状态暴露为模型可见输入之前,不会影响 KV Cache
## 已知限制与暂缓事项
@@ -55,4 +55,4 @@
- **只有 Round 数量预算**`maxGoalRounds` 不计量 token、货币、挂钟时间或提供方配额。
- **没有独立评估器**:记录完成或阻塞的调用方拥有最终决定权;由评估器支持的认证暂缓到独立策略层。
- **只有一个当前目标**:系统有意不支持并行目标或独立目标数据库;替换或清除后,历史仍可在会话日志中读取。
- **信任进程内生产方**:能直接访问 `Session` 的插件可以追加伪造的 goal 来源数据。严格回放会检测格式错误或不一致的记录,并使 goal 访问从该记录起失败,直到日志修复;这是完整性检测,不是插件隔离。
- **信任进程内生产方**:能直接访问 `Session` 的插件可以追加伪造的 `goal/change` 数据。严格回放会检测格式错误或不一致的记录,并使 goal 访问从该记录起失败,直到日志修复;这是完整性检测,不是插件隔离。

View File

@@ -35,7 +35,7 @@ export type GoalOperation =
| 'block'
| 'clear'
/** Full-snapshot goal mutation retained in a model-visible context event. */
/** Full-snapshot goal mutation committed by a durable `goal/change` event. */
export interface GoalSnapshotChangeMeta {
readonly kind: 'goal/change'
readonly version: 1
@@ -55,24 +55,17 @@ 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. */
export type GoalMessageSource = {
/** 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
/**
* Round-zero state changes are `notice`-form contexts; a continuation round
* carries the objective forward as ordinary context and declares no form.
* Discriminated so the account cannot be omitted when the form is declared.
*/
} & ({ readonly form: 'notice'; readonly summary: string } | { readonly form?: never; readonly summary?: never })
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
@@ -80,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. */
@@ -106,7 +108,7 @@ export interface EditGoalRequest {
readonly maxGoalRounds?: number
}
/** Live notification after one goal mutation has been accepted for logging. */
/** Live notification after one durable goal mutation commits. */
export interface GoalChanged {
readonly operation: GoalOperation
readonly ref: GoalRef
@@ -129,9 +131,8 @@ export type GoalErrorCode =
declare module 'cordis' {
interface Events {
/**
* Goal mutation accepted by one live agent. The matching context event is
* already appended or queued in that agent's active tool-batch FIFO.
* 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.

View File

@@ -2,7 +2,6 @@
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } 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 {
@@ -14,8 +13,6 @@ import type {
GoalSnapshotChangeMeta,
} from './domain.ts'
type UserMessageEvent = Extract<SessionEvent, { type: 'user/message' }>
const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Set([
'create',
'edit',
@@ -179,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
@@ -309,60 +306,21 @@ export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): v
}
/**
* Decode and verify one model-visible goal state change without folding it. A
* goal state change is a round-zero goal-sourced `user/message` carrying the
* complete change in its source; any other user message returns `undefined`.
* A mismatched attribution, source change, or rendered body
* fails replay loudly.
* @param event - user message whose source and rendered content must agree.
* @returns validated change, or `undefined` when the message is not a goal state change.
*/
export function decodeGoalEvent(event: UserMessageEvent): GoalChangeMeta | undefined {
const source = goalSource(event.data.source)
if (source === undefined) {
const [block] = event.data.content
if (block?.type === 'text' && block.text.startsWith('<goal_state>')) {
throw new Error(`goal change at session event ${event.seq} 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 session event ${event.seq} lacks source change data`)
const ref = goalChangeRef(change)
if (source.goalId !== ref.id || source.revision !== ref.revision) {
throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`)
}
if (JSON.stringify(event.data.content) !== JSON.stringify(renderGoalChange(change))) {
throw new Error(`goal change at session event ${event.seq} has mismatched model-visible content`)
}
return change
}
/**
* Apply one session event and return its goal change, when present.
* Apply one session event to the strict durable goal fold.
* @param state - mutable fold accumulator.
* @param event - next event in sequence order.
* @returns decoded change for pending-overlay reconciliation.
*/
export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalChangeMeta | undefined {
export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): void {
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') {
// A goal state change carries a complete source change (round zero).
const change = decodeGoalEvent(event)
if (change !== undefined) {
applyGoalChange(state, change)
return change
}
const source = goalSource(event.data.source)
if (source === undefined) return undefined
// 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 -- decodeGoalEvent 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`)
}
if (source === undefined) return
const current = state.goal
if (current === undefined || current.phase !== 'active' || source.goalId !== current.id
|| source.revision !== current.revision || source.round !== state.roundsStarted + 1
@@ -371,7 +329,6 @@ export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalC
}
state.roundsStarted = source.round
}
return undefined
}
/**

View File

@@ -11,19 +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 } from '@deepseek-ai/dsh-session'
// Type-only: resolves ctx.sessionProjections for the optional unit child.
import type {} from '@deepseek-ai/dsh-session-projection'
import {
applyGoalChange,
applyGoalEvent,
decodeGoalEvent,
decodeGoalChange,
emptyGoalFoldState,
goalChangeRef,
} from './fold.ts'
import type { GoalFoldState } from './fold.ts'
import { goalChangeSummary, renderGoalChange } from './render.ts'
import {
GOAL_CHANGE_VERSION,
GoalError,
@@ -56,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 {
@@ -96,22 +92,22 @@ const goalProjectionSchema: ZodType<GoalProjection | null> = zod.union([
* @returns the next projection (same reference when the event is not a goal change).
*/
export function applyGoalProjection(state: GoalProjection | null, event: SessionEvent): GoalProjection | null {
if (event.type !== 'user/message') return state
const source = event.data.source
if (source.kind !== 'goal' || source.round !== 0) return state
const change = source.change
// Session-log data is a durable boundary: the static type promises the kind,
// but a foreign or corrupted change record must degrade to same-reference,
// never feed the zod parse in the registry drive.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable-boundary guard
if (change === undefined || change.kind !== 'goal/change') return state
if (change.operation === 'clear') return null
return {
goal: change.goal,
roundsStarted: change.roundsStarted,
createdAt: change.createdAt,
updatedAt: change.updatedAt,
if (event.type !== 'goal/change') return state
let change: GoalChangeMeta | undefined
try {
change = decodeGoalChange(event.data)
} catch (_invalidPersistedGoalChange) {
return state
}
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. */
@@ -126,19 +122,12 @@ export interface ResolvedConfig {
defaultMaxGoalRounds: number
}
/** One accepted mutation waiting to enter or be observed in the session log. */
interface PendingGoalChange {
readonly change: GoalChangeMeta
readonly activation: GoalActivation
applied: boolean
}
/** Process-local cache plus mutations waiting in the active tool-batch FIFO. */
/** Process-local cache plus activation intent crossing the synchronous append boundary. */
interface GoalCache {
readonly state: GoalFoldState
activation: GoalActivation
observedSeq: number
readonly pending: PendingGoalChange[]
pendingActivation: { readonly seq: number; readonly activation: GoalActivation } | undefined
}
/** Validated create input with every deployment default materialized. */
@@ -188,11 +177,6 @@ function resolveBlockReason(reason: unknown): GoalBlockReason {
return { code, message: message.trim() }
}
/** Compare the complete canonical payloads used for deferred reconciliation. */
function sameChange(left: GoalChangeMeta, right: GoalChangeMeta): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
/** Goal service (`ctx.goals`) backed exclusively by the owning session log. */
export class GoalService extends Service {
static inject = ['agents']
@@ -222,7 +206,7 @@ export class GoalService extends Service {
init: () => null,
apply: applyGoalProjection,
view: state => state,
stateVersion: 1,
stateVersion: 4,
})
})
}
@@ -436,34 +420,21 @@ export class GoalService extends Service {
state,
activation: 'disarmed',
observedSeq: session.seq,
pending: [],
pendingActivation: undefined,
}
this.caches.set(session, cache)
return cache
}
/** Incrementally observe durable events without losing deferred mutations. */
/** 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)) {
// A goal state change is a round-zero goal-sourced user message; a
// positive round is a continuation prompt handled by applyGoalEvent.
if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round === 0) {
const change = decodeGoalEvent(event)
if (change !== undefined) {
const pending = cache.pending[0]
if (pending !== undefined && sameChange(pending.change, change)) {
if (!pending.applied) {
applyGoalChange(cache.state, change)
cache.activation = pending.activation
pending.applied = true
}
cache.pending.shift()
cache.observedSeq += 1
continue
}
}
}
applyGoalEvent(cache.state, event)
if (event.type === 'goal/change') {
cache.activation = cache.pendingActivation?.seq === event.seq
? cache.pendingActivation.activation
: 'disarmed'
}
cache.observedSeq += 1
}
}
@@ -555,42 +526,21 @@ export class GoalService extends Service {
}
this.commit(agent, cache, change, activation)
const view = this.view(cache)
/* v8 ignore next -- applyGoalChange installs the snapshot immediately 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 log/FIFO, 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 pending: PendingGoalChange = { change, activation, applied: false }
cache.pending.push(pending)
cache.pendingActivation = { seq: agent.session.seq, activation }
try {
agent.inject(createUserMessage({
content: renderGoalChange(change),
source: {
kind: 'goal',
goalId: ref.id,
revision: ref.revision,
round: 0,
change,
form: 'notice',
summary: goalChangeSummary(change),
},
}))
} catch (error: unknown) {
const index = cache.pending.indexOf(pending)
/* v8 ignore next -- a committed goal append cannot reject after its contained observers run */
if (index < 0) throw new Error('goal injection failed after its pending mutation was reconciled', { cause: error })
cache.pending.splice(index, 1)
throw error
agent.session.append('goal/change', change)
this.sync(agent.session, cache)
} finally {
cache.pendingActivation = undefined
}
if (!pending.applied) {
applyGoalChange(cache.state, change)
cache.activation = activation
pending.applied = true
}
this.sync(agent.session, cache)
const goal = this.view(cache)
const notification: GoalChanged = {
operation: change.operation,

View File

@@ -1,35 +0,0 @@
/** Model-visible rendering for durable goal mutations. */
import { boundContextSummary } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { GoalChangeMeta } from './domain.ts'
/**
* One-line account of a goal mutation for the `notice` form's collapsed row.
* @param change - durable goal change carried by the message source.
* @returns the operation and, for a surviving goal, its objective.
*/
export function goalChangeSummary(change: GoalChangeMeta): string {
// The row header already names the producer, so the account does not repeat
// it. The objective is unbounded caller text, so the account is bounded.
return boundContextSummary(change.operation === 'clear'
? change.operation
: `${change.operation}: ${change.goal.objective}`)
}
/**
* 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: `<goal_state>${JSON.stringify(payload)}</goal_state>` }]
}

View File

@@ -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.
*/

View File

@@ -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))
@@ -44,20 +44,16 @@ describe('goal domain through a real cordis.yml and headless process', () => {
const result = JSON.parse(stdout) as Record<string, unknown>
expect(result).toMatchObject({
type: 'result',
success: true,
})
expect(result['result']).toBeTypeOf('string')
expect(result['result']).toContain('CLI tool round trip complete')
expect(result['output']).toBeTypeOf('string')
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',
@@ -69,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)

View File

@@ -1,78 +1,58 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { createUserMessage, HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm'
import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
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, GoalChanged, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
type DeferredInjection = UserMessage
import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
interface StubAgent {
agent: Agent
session: Session
deferred: DeferredInjection[]
setDeferred(value: boolean): void
setStatus(value: AgentStatus): void
drain(): void
}
/** Number the next balanced one-shot injection turn. */
/** Number the next balanced test-fixture turn. */
function nextTurn(session: Session): number {
return session.events.reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1
}
/** Mirror the public Agent.inject contract for domain tests. */
function appendInjection(session: Session, input: UserMessage): void {
session.append('user/message', input, { surfaceOp: 'append' })
new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }).append('next-step', input)
}
/** Build a registry-compatible agent around one concrete session. */
function stubAgentForSession(session: Session): StubAgent {
const id = session.id
const deferred: DeferredInjection[] = []
let shouldDefer = false
let status: AgentStatus = 'idle'
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const agent: Agent = {
id,
options: {},
session,
inbox,
ctx: new Context(),
get status() { return status },
get acceptsNextStep() { return status === 'running' },
status: 'idle',
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(input) {
if (shouldDefer) deferred.push(input)
else appendInjection(session, input)
},
reserveTurnAdmission: () => undefined,
steer: () => {},
inject(input) { inbox.append('next-step', input) },
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
return {
agent,
session,
deferred,
setDeferred(value) { shouldDefer = value },
setStatus(value) { status = value },
drain() {
shouldDefer = false
for (const injection of deferred.splice(0)) appendInjection(session, injection)
},
}
}
/** Build a registry-compatible agent with controllable context deferral. */
/** Build a registry-compatible agent around a fresh session. */
function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[]): StubAgent {
return stubAgentForSession(Session.create(SessionId(rawId), seed))
}
@@ -90,7 +70,7 @@ async function harness(config: { defaultMaxGoalRounds?: number } = {}) {
function appendRound(session: Session, ref: GoalRef, round: number): void {
const source = { kind: 'goal', goalId: ref.id, revision: ref.revision, round } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `round ${round}` }], source,
}), { surfaceOp: 'append' })
@@ -98,7 +78,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 })
@@ -119,16 +99,15 @@ describe('GoalService creation and replay', () => {
})
expect(goal.id).toMatch(/^goal-/)
expect(seen).toEqual(['create'])
expect(session.events.map(event => event.type)).toEqual(['user/message'])
expect(session.events.map(event => event.type)).toEqual(['goal/change'])
const context = session.events[0]
expect(context?.type).toBe('user/message')
if (context?.type !== 'user/message') throw new Error('expected goal context')
expect(context.data.source).toMatchObject({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 })
const change = context.data.source.kind === 'goal' ? decodeGoalChange(context.data.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(context.data.content).toEqual(renderGoalChange(change))
expect(session.deriveMessages()).toEqual([context.data])
expect(agent.inbox.nextStep).toEqual([])
expect(session.deriveMessages()).toEqual([])
expect(foldGoal(session.events)).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 })
vi.useRealTimers()
})
@@ -381,25 +360,6 @@ describe('GoalService mutations', () => {
expect(next.id).not.toBe(goal.id)
})
it('emits bare compare-and-set refs in folded lastRef and goal/changed notifications', async () => {
const { ctx, agent, session } = await harness()
const seen: GoalChanged['ref'][] = []
ctx.on('goal/changed', (_subject, change) => { seen.push(change.ref) })
const created = ctx.goals.create(agent, { objective: 'bare refs', maxGoalRounds: 3 })
const edited = ctx.goals.edit(agent, created, { objective: 'bare refs edited' })
const blocked = ctx.goals.block(agent, edited, { code: 'bare-blocker', message: 'Bare refs.' })
// GoalRef is exactly { id, revision }: every notification ref must be bare.
for (const ref of seen) {
expect(Object.keys(ref).sort()).toEqual(['id', 'revision'])
expect(ref).toEqual({ id: created.id, revision: ref.revision })
}
expect(seen).toHaveLength(3)
// The durable fold's lastRef is the same bare ref, not a full snapshot.
const folded = foldGoal(session.events)
expect(folded.lastRef).toEqual({ id: blocked.id, revision: blocked.revision })
expect(Object.keys(folded.lastRef as object).sort()).toEqual(['id', 'revision'])
})
it('keeps per-goal mutation timestamps monotonic when the wall clock moves backward', async () => {
vi.useFakeTimers()
vi.setSystemTime(100)
@@ -411,10 +371,8 @@ describe('GoalService mutations', () => {
vi.setSystemTime(80)
ctx.goals.clear(agent, goal)
const clear = session.events
.filter(event => event.type === 'user/message' && event.data.source.kind === 'goal')
.map(event => event.type === 'user/message' && event.data.source.kind === 'goal'
? decodeGoalChange(event.data.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()
@@ -432,23 +390,15 @@ describe('GoalService mutations', () => {
expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer'))
})
it('preserves multiple pending revisions until deferred injections enter the log', async () => {
const test = await harness()
const { ctx, agent, session, deferred } = test
test.setDeferred(true)
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(deferred).toHaveLength(3)
expect(session.events).toHaveLength(0)
appendInjection(session, createUserMessage({
content: [{ type: 'text', text: 'unrelated' }], source: { kind: 'plugin', plugin: 'test' },
}))
expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' })
test.drain()
expect(deferred).toHaveLength(0)
expect(session.events.map(event => event.type)).toEqual([
'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' } })
})
@@ -462,7 +412,7 @@ describe('GoalService mutations', () => {
ctx.agents.register(stub.agent)
let observed: ReturnType<GoalService['get']>
ctx.on('session/event', (session, event) => {
if (session === stub.session && event.type === 'user/message' && event.data.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' })
@@ -472,36 +422,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 deferred goal mutations that enter the log out of FIFO order', async () => {
const test = await harness()
test.setDeferred(true)
const created = test.ctx.goals.create(test.agent, { objective: 'ordered' })
test.ctx.goals.edit(test.agent, created, { objective: 'ordered edit' })
const second = test.deferred[1]
if (second === undefined) throw new Error('expected a second deferred goal mutation')
appendInjection(test.session, second)
expect(() => test.ctx.goals.get(test.agent)).toThrow('advance the current goal')
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 () => {
@@ -522,13 +456,7 @@ describe('GoalService mutations', () => {
createdAt: 12,
updatedAt: 12,
}
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('user/message', createUserMessage({
content: renderGoalChange(change), source,
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
session.append('goal/change', change)
expect(ctx.goals.get(agent)).toMatchObject({
id: change.goal.id,
@@ -555,17 +483,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')
@@ -592,30 +511,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 turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('user/message', createUserMessage({
content: overrides.content ?? renderGoalChange(change),
source,
}), { 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 = Session.create(SessionId(`validation-${Math.random()}`))
appendChange(session, change, overrides)
appendChange(session, change)
return session.events
}
@@ -643,6 +545,21 @@ describe('goal replay validation', () => {
}
}
it('keeps durable goal state independent from inbox changes', () => {
const change = snapshotChange()
const session = Session.create(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: () => {}, claimed: () => {} })
inbox.append('next-step', message)
expect(inbox.remove(message.id)).toBe(true)
expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } })
})
function foldPair(first: GoalSnapshotChangeMeta, second: GoalChangeMeta): ReturnType<typeof foldGoal> {
const session = Session.create(SessionId(`validation-pair-${Math.random()}`))
appendChange(session, first)
@@ -661,7 +578,7 @@ describe('goal replay validation', () => {
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 })
const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'ordinary' }], source,
}), { surfaceOp: 'append' })
@@ -799,16 +716,16 @@ 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 = Session.create(SessionId('goal-source-without-meta'))
const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
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', () => {
@@ -844,21 +761,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 = Session.create(SessionId('fold-clear'), oneChange(change))
@@ -869,13 +771,7 @@ describe('goal replay validation', () => {
cleared: { id: change.goal.id, revision: 2 },
clearedAt: 20,
}
const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0, change: clear } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('user/message', createUserMessage({
content: renderGoalChange(clear), source,
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
appendChange(session, clear)
expect(foldGoal(session.events)).toEqual({
roundsStarted: 0,
lastRef: { id: change.goal.id, revision: 2 },

View File

@@ -3,7 +3,6 @@ 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<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -46,19 +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'))
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
session.append('user/message', createUserMessage({
content: renderGoalChange(change),
source: changeSource,
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', {
turn: 2,
trigger: {
kind: 'message',
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 },
},
})
session.append('goal/change', change)
session.append('turn/start', { turn: 1 })
expect(() => {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'continue' }],
@@ -67,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'))
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
expect(() => {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'counterfeit' }],
source: changeSource,
}), { surfaceOp: 'append' })
session.append('goal/change', { ...change, extra: true } as never)
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-goal',
}))
expect(session.seq).toBe(1)
expect(session.seq).toBe(0)
expect(() => {
session.append('user/message', createUserMessage({
content: renderGoalChange(change),
source: changeSource,
}), { surfaceOp: 'append' })
session.append('goal/change', change)
}).not.toThrow()
})
@@ -93,22 +66,11 @@ describe('goal stream invariants', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('goal-invariant-late-load'))
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
session.append('user/message', createUserMessage({
content: renderGoalChange(change),
source: changeSource,
}), { 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,
trigger: {
kind: 'message',
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 },
},
})
session.append('turn/start', { turn: 1 })
expect(() => {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'continue after load' }],

View File

@@ -10,14 +10,14 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import GoalService, { applyGoalProjection } from '@deepseek-ai/dsh-goal'
import GoalService, { applyGoalProjection, foldGoal } from '@deepseek-ai/dsh-goal'
import type { GoalRef } from '@deepseek-ai/dsh-goal'
interface Bench {
@@ -31,22 +31,22 @@ interface Bench {
/** Register a minimal registry-compatible live agent over a store session. */
function liveAgent(ctx: Context, session: Session): Agent {
const status: AgentStatus = 'idle'
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const agent: Agent = {
id: session.id,
options: {},
session,
inbox,
ctx,
get status() { return status },
get acceptsNextStep() { return false },
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(input: UserMessage) {
session.append('user/message', input, { surfaceOp: 'append' })
inbox.append('next-step', input)
},
reserveTurnAdmission: () => undefined,
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
ctx.agents.register(agent)
@@ -125,37 +125,65 @@ describe('goal projection unit', () => {
}
})
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' })
bench.ctx.goals.clear(bench.agent, created)
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('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
// (a throwing apply would tear down every registered unit's drive), so
// its transition is exercised directly as the pure function it is.
const user = { type: 'user/message', seq: 0, time: 1, data: createUserMessage({
const plainUser = createUserMessage({
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'user' },
}) } as never
expect(applyGoalProjection(null, user)).toBeNull()
const malformed = { type: 'user/message', seq: 1, time: 2, data: createUserMessage({
content: [{ type: 'text', text: 'broken' }],
source: { kind: 'goal', goalId: 'g-broken', revision: 1, round: 0 } as never,
}) } as never
})
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
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
expect(applyGoalProjection(current, queuedUser)).toBe(current)
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(state, malformed)).toBe(state)
expect(applyGoalProjection(null, malformed)).toBeNull()
expect(applyGoalProjection(current, malformed)).toBe(current)
expect(applyGoalProjection(empty, malformed)).toBe(empty)
const queuedRound = {
type: 'agent/inbox/spliced', seq: 3, time: 4,
data: { target: 'next-step', start: 0, inserted: [createUserMessage({
content: [{ type: 'text', text: 'later round' }],
source: { kind: 'goal', goalId: 'g1', revision: 1, round: 1 } as never,
})] },
} as never
expect(applyGoalProjection(current, queuedRound)).toBe(current)
// 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, trigger: { kind: 'message', source: { kind: 'user' } } } } as never
expect(applyGoalProjection(state, turnStart)).toBe(state)
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 foreignKind = { type: 'user/message', seq: 2, time: 3, data: createUserMessage({
content: [{ type: 'text', text: 'foreign' }],
source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0, change: { kind: 'not-a-goal-change' } } as never,
}) } as never
expect(applyGoalProjection(state, foreignKind)).toBe(state)
// 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)
})
it('has no goal key when the goal service is not composed', async () => {

View File

@@ -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: 2fa80c2e5fa3d675a48fc18506635fd811ac8f80
README.zh.md: a97c038d949e64c0323e881449ed6fa54064f826
README.md: c8c1ab84c237ee34db7abcd63962476693b5e56c
README.zh.md: 7120280fcaa8710e5bab819a0539c926ab4e424a

View File

@@ -14,7 +14,7 @@ All calls are exclusive, so a model-ordered batch observes earlier mutations and
All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON.
An autonomous goal round that successfully reports `complete` or `blocked` defers one wrap-up context onto that tool result: an injected instruction telling the model to write a final closing message to the user and call no more tools, after which the turn ends through the ordinary no-tool-calls stop. Direct-human mutations receive no instruction: the assistant may acknowledge the change and concurrent human steering remains available to the loop.
An autonomous goal round that successfully reports `complete` or `blocked` marks that tool execution with `concludeTurn()` so the physical turn stops after the step. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop.
## Authority
@@ -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. Mutation results are followed by the goal domain's raw `<goal_state>` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. A goal-round `complete` or `blocked` result additionally injects one `<goal_complete>`/`<goal_blocked>` wrap-up instruction that asks for a grounded closing message to the user without further tool calls.
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. Mutations also retain the domain snapshot until compaction. A goal-round terminal update adds the injected wrap-up instruction and one further model request for the closing message — once per goal lifecycle, not per round.
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 resulting 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

View File

@@ -14,7 +14,7 @@
3 个规范值都与已经渲染给 Native 调用方的紧凑 JSON 一致:`{ goal: null }``{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`。因此,编程消费方无需解析渲染后的 JSON即可收到相同领域结构。
自主 Goal Round 成功报告 `complete``blocked` 时,会在该次工具结果上附带一条收尾注入指令,要求模型面向用户写出最终收尾消息、不再调用工具,之后轮次经由常规的无工具调用停止路径结束。人类直接变更不会收到这条指令assistant 可以确认变更,循环仍可接收并发的人类 steering中途引导
自主 Goal Round 成功报告 `complete``blocked` 时,会`concludeTurn()` 标记该次工具执行,使物理轮次在该步骤后停止。人类直接变更不会导致这种停止assistant 可以确认变更,循环仍可接收并发的人类 steering中途引导
## 权限
@@ -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 领域产生的原始 `<goal_state>` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。Goal Round 的 `complete`/`blocked` 结果还会额外注入一条 `<goal_complete>`/`<goal_blocked>` 收尾指令,要求模型向用户写出有依据的收尾消息且不再调用工具。
生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更会追加 goal 领域的持久 `goal/change` 事件,而不会把模型上下文排队。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。
#### Token 影响
固定 schema 成本,加上每次调用的一条紧凑结果。变更还会保留领域快照直到压缩compaction。Goal Round 的终态更新会增加注入的收尾指令和一次额外的模型请求用于收尾消息——每个 goal 生命周期一次,而非每个 Goal Round 一次
固定 schema 成本,加上每次调用的一条紧凑结果。持久变更不会增加单独的模型可见上下文
#### KV Cache 影响
schema 的定义与可见性不变时,前缀保持稳定。调用结果和生成的 goal 快照会追加到可复用请求前缀之后,不会使更早条目失效。
schema 的定义与可见性不变时,前缀保持稳定。调用结果会追加到可复用请求前缀之后,不会使更早条目失效。
## 已知限制与暂缓事项

View File

@@ -70,8 +70,7 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE
function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean {
if (!ctx.agents.roots().includes(execution.agent)) return false
return execution.events.some(event =>
(event.type === 'user/message' && event.data.source.kind === 'user')
|| (event.type === 'steering/message' && event.data.message.source.kind === 'user'))
event.type === 'user/message' && event.data.source.kind === 'user')
}
/** Whether this turn is the current goal's exact admitted round. */

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
import type { GoalRef } from '@deepseek-ai/dsh-goal'
@@ -21,7 +21,7 @@ interface StubAgent {
setStatus(status: AgentStatus): void
}
/** Build one registry-compatible live agent whose injections append in place. */
/** Build one registry-compatible live agent whose injections enter the durable inbox. */
function stubAgent(rawId: string, supplied?: Session): StubAgent {
const session = supplied ?? Session.create(SessionId(rawId))
let status: AgentStatus = 'running'
@@ -29,18 +29,17 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
id: session.id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
get status() { return status },
get acceptsNextStep() { return status === 'running' },
ctx: new Context(),
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
this.inbox.append('next-step', input)
},
reserveTurnAdmission: () => undefined,
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
return { agent, session, setStatus(value) { status = value } }
@@ -51,11 +50,17 @@ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): numb
const turn = stub.session.events
.filter(event => event.type === 'turn/start')
.reduce((max, event) => Math.max(max, event.data.turn), 0) + 1
stub.session.append('turn/start', { turn, trigger: { kind: 'message', source } })
stub.session.append('user/message', createUserMessage({
const message = createUserMessage({
content: [{ type: 'text', text }],
source,
}), { surfaceOp: 'append' })
})
stub.agent.inbox.append('next-turn', message)
const claimed = stub.agent.inbox.claim('next-turn', turn)
if (claimed.length === 0) throw new Error('expected queued turn input')
stub.session.append('turn/start', { turn })
for (const admitted of claimed) {
stub.session.append('user/message', admitted, { surfaceOp: 'append' })
}
return turn
}
@@ -300,16 +305,13 @@ describe('goal tool execution authority', () => {
const humanTurn = openTurn(root, { kind: 'user' })
const created = ctx.goals.create(root.agent, { objective: 'steer me' })
closeTurn(root, humanTurn)
const round = openTurn(root, {
openTurn(root, {
kind: 'goal', goalId: created.id, revision: created.revision, round: 1,
})
root.session.append('steering/message', {
turn: round,
message: createUserMessage({
content: [{ type: 'text', text: 'pause now' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })
root.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'pause now' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const paused = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'pause',
}, root.agent)