From 0129063ae7d45e0eedbee9b2055b98cfb13e5d95 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:22:10 +0800 Subject: [PATCH 01/11] feat(goal): add model-facing goal tools --- docs/config-catalog.md | 14 + docs/module-graph.md | 8 + docs/rfc/INDEX.md | 1 + ...26-07-19-model-facing-goal-tools.i18n.yaml | 6 + .../2026-07-19-model-facing-goal-tools.md | 63 +++ .../2026-07-19-model-facing-goal-tools.zh.md | 63 +++ docs/tool-catalog.md | 89 +++++ .../tests/fixtures/goal/tool-goal/cordis.yml | 26 ++ .../fixtures/goal/tool-goal/scripted-llm.ts | 88 +++++ examples/package.json | 1 + knip.json | 5 + .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/goal/README.md | 1 + packages/goal/tool-goal/README.md | 55 +++ packages/goal/tool-goal/package.json | 47 +++ packages/goal/tool-goal/src/authority.ts | 105 +++++ packages/goal/tool-goal/src/index.ts | 222 +++++++++++ .../goal/tool-goal/tests/tool-goal.e2e.ts | 124 ++++++ .../goal/tool-goal/tests/tool-goal.spec.ts | 374 ++++++++++++++++++ packages/goal/tool-goal/tsconfig.json | 39 ++ pnpm-lock.yaml | 37 ++ scripts/gen-tool-catalog.ts | 17 + tsconfig.build.json | 1 + tsconfig.json | 1 + 24 files changed, 1388 insertions(+), 1 deletion(-) create mode 100644 docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md create mode 100644 examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml create mode 100644 examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts create mode 100644 packages/goal/tool-goal/README.md create mode 100644 packages/goal/tool-goal/package.json create mode 100644 packages/goal/tool-goal/src/authority.ts create mode 100644 packages/goal/tool-goal/src/index.ts create mode 100644 packages/goal/tool-goal/tests/tool-goal.e2e.ts create mode 100644 packages/goal/tool-goal/tests/tool-goal.spec.ts create mode 100644 packages/goal/tool-goal/tsconfig.json diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6473848ab3..8f95bad05b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1098,6 +1098,20 @@ export interface Config { Source: [`packages/fs/tool-fs-search/src/index.ts:59`](../packages/fs/tool-fs-search/src/index.ts) +## `@deepseek-ai/dsh-tool-goal` + +Requires: `agents` · `goals` · `tools` · `systemPrompt` + +```ts config-catalog +/** Model policy and hard lower bounds for goal-state updates. */ +export interface Config { + /** Minimum admitted goal rounds before the model may self-report `blocked`. */ + blockedAfterConsecutiveRounds?: number +} +``` + +Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/src/index.ts) + ## `@deepseek-ai/dsh-tool-skill` Requires: `tools` · `skills` diff --git a/docs/module-graph.md b/docs/module-graph.md index 56934f8495..23879ed342 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -30,6 +30,7 @@ flowchart TD end subgraph group_goal["packages/goal"] pkg_goal["goal"] + pkg_tool_goal["tool-goal"] end subgraph group_bash["packages/bash"] pkg_bash["bash"] @@ -261,6 +262,12 @@ flowchart TD pkg_agent_loop --> pkg_session_persistence pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools + pkg_tool_goal --> pkg_agent + pkg_tool_goal --> pkg_goal + pkg_tool_goal --> pkg_llm + pkg_tool_goal --> pkg_session + pkg_tool_goal --> pkg_system_prompt + pkg_tool_goal --> pkg_tools pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_bash pkg_tool_bash --> pkg_home @@ -511,6 +518,7 @@ flowchart TD | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index e6d4ef306b..d3c408f214 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -89,6 +89,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 | | [Durable per-step time context](implemented/feature/2026-07-16-durable-per-step-time-context.md) | 2026-07-16 | | [Dedicated full-screen TUI front door](implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) | 2026-07-17 | +| [Model-facing same-session goal tools](implemented/feature/2026-07-19-model-facing-goal-tools.md) | 2026-07-19 | | [Persisted same-session goal domain](implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) | 2026-07-19 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml new file mode 100644 index 0000000000..0d66c0c360 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-model-facing-goal-tools.md: 7a207bbc73e13ca5111d73ee2c58cf05fbe6387e +2026-07-19-model-facing-goal-tools.zh.md: 46ad7671e1ef2a6856df6d1ec893896aab9760b5 diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md new file mode 100644 index 0000000000..7a207bbc73 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -0,0 +1,63 @@ +# RFC: Model-facing same-session goal tools + +Status: implemented + +English | [中文](2026-07-19-model-facing-goal-tools.zh.md) + +## Problem + +The persisted goal domain deliberately exposes lifecycle verbs to plugins, not directly to a model. A model still needs a small control surface for discovering the current goal, creating one from human intent, and changing its lifecycle. Prompt guidance alone cannot establish who authorized a mutation: a subagent, injected plugin message, stale model turn, or resumed session could all produce the same tool arguments. + +The surface also needs to preserve the separation between durable state and live execution authority. A restored or forked session can replay an active goal but starts disarmed; a later human request such as “continue” should let the model rearm it without requiring a literal command phrase. Conversely, an admitted autonomous goal round must be able to report completion or a persistent blocker without gaining permission to edit, pause, resume, or replace the human objective. + +## Decision + +`@deepseek-ai/dsh-tool-goal` in `packages/goal/tool-goal/` contributes three exclusive tools and one system-prompt policy section over `ctx.goals`: `get_goal`, `create_goal`, and `update_goal`. The names and read-create-update shape follow Codex's compact goal tool surface while the authority rules use this repository's public agent, session, tool, and goal seams. + +### Tools and model contract + +`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`. + +The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker. + +All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state. + +### Execution authority + +Every call requires an `exec.agent` that is the exact running object in `AgentRegistry`, is the current inherited driver initiator, and has an open turn. These are execution-time checks and cannot be bypassed by prompt injection or hand-authored tool arguments. + +Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model. + +Complete and blocked accept either direct-human authority or the exact current goal round. Goal-round authority requires a goal-sourced `user/message` whose goal id, revision, and round all equal the folded current goal. It grants only the two terminal reports. Direct human authority may stop a goal immediately. + +### Blocking threshold + +`blockedAfterConsecutiveRounds` is a validated positive safe-integer configuration with default `3`. When an autonomous goal round calls `blocked`, the plugin mechanically requires at least that many admitted rounds; the configured value also appears in model guidance. The runtime cannot determine whether those rounds encountered the same blocking condition, so semantic equivalence remains a model judgment. This count is deliberately separate from the goal's generous continuation cap. + +## Testing + +Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact live-agent and driver checks, root-versus-child authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, rearming after a session-start edge, compare-and-set and argument failures, exact goal-round completion, the configured blocking threshold, and immediate human blocking. A keyless Loader/stdio process test mounts the real goal, tool, loop, and persistence plugins through `cordis.yml`, drives scripted model tool calls, and reads the JSONL externally to verify the model-visible create/pause snapshots, structured tool results, and configured prompt text. + +## Alternatives considered + +- **Rely on prompt instructions for authority** — rejected because text can guide model judgment but cannot authenticate the live caller, turn, or source event. +- **Expose every goal-service verb as a separate tool** — rejected because a compact read/create/update surface reduces schema cost and keeps compare-and-set behavior uniform. +- **Require exact command phrases** — rejected because natural-language intent, including languages other than English, should be interpreted by the model; execution authority depends on provenance rather than spelling. +- **Authorize from persisted root or fork metadata** — rejected because a fork that becomes an independently resumed top-level session should accept new human authority, while a currently owned child should not. +- **Let autonomous rounds edit or resume the goal** — rejected because continuation authority is narrower than authority to redefine or restart the human objective. +- **Treat the blocked threshold as an evaluator** — rejected because event counts cannot prove that an obstacle is semantically unchanged or truly terminal. + +## Consequences + +- Models receive a stable, compact lifecycle surface without direct access to the goal service. +- State-changing calls are constrained by live runtime provenance as well as durable compare-and-set references. +- Human requests can create and rearm goals through ordinary natural language, while restored sessions remain inert until such input arrives. +- Goal rounds can finish or report a repeated blocker but cannot broaden their own mandate. +- Deployment policy selects the blocking lower bound; the same resolved value controls enforcement and prompt guidance. + +## Known limitations and deferred work + +- Semantic classification of a substantial goal, a request to continue, objective completion, and the same blocking condition remains model judgment. An independent evaluator or completion certificate is deferred. +- These tools mutate goal state but do not schedule goal rounds, classify abnormal driver stops, or cancel an active turn; the same-session driver owns those behaviors. +- Human slash-command discovery and rendering are deferred to the command-surface layer. +- A scope can hide tool registrations while leaving the independently registered prompt section visible unless the deployment scopes both together. diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md new file mode 100644 index 0000000000..46ad7671e1 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -0,0 +1,63 @@ +# RFC:面向模型的同会话目标工具 + +Status: implemented + +[English](2026-07-19-model-facing-goal-tools.md) | 中文 + +## 问题 + +持久目标领域有意把生命周期动词提供给插件,而不直接提供给模型。模型仍然需要一个小型控制面,用于发现当前目标、根据人类意图创建目标并改变其生命周期。仅靠提示词指导无法确定是谁授权了一次变更:子智能体、注入的插件消息、陈旧的模型轮次或恢复后的会话都可能产生相同的工具参数。 + +该表面还需要保持持久状态与实时执行权限之间的分离。恢复或 fork(派生)后的会话可以回放活跃目标,但初始处于未激活状态;后续人类提出“继续”之类的请求时,模型应能重新激活目标,而无需用户使用字面命令。相反,已接纳的自主目标回合必须能够报告完成或持续阻塞,却不能因此获得编辑、暂停、恢复或替换人类目标的权限。 + +## 决策 + +位于 `packages/goal/tool-goal/` 的 `@deepseek-ai/dsh-tool-goal` 在 `ctx.goals` 之上贡献三个独占工具和一个系统提示词策略段:`get_goal`、`create_goal` 与 `update_goal`。工具名称和读取—创建—更新形态遵循 Codex 的紧凑目标工具表面,而权限规则使用本仓库公共的 agent(智能体)、会话、工具与目标接缝。 + +### 工具与模型契约 + +`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效。 + +提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞。 + +三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。 + +### 执行权限 + +每次调用都要求存在 `exec.agent`,且它必须是 `AgentRegistry` 中完全相同的运行中对象、当前继承的驱动发起者,并处于开放轮次内。这些检查在执行时进行,不能通过提示词注入或手写工具参数绕过。 + +创建、编辑、暂停与恢复还要求运行时根智能体的当前轮次已经接纳一条用户消息或用户 steering(转向)事件。根所有权来自实时智能体图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子智能体则仍然是子智能体,不能改变这些状态。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 + +完成与阻塞既接受直接人类权限,也接受准确的当前目标回合。目标回合权限要求存在一条来源为目标的 `user/message`,其中目标 id、修订号和回合都与折叠后的当前目标相等。它只授予这两种终止报告权限。直接人类权限可以立即停止目标。 + +### 阻塞阈值 + +`blockedAfterConsecutiveRounds` 是经过校验的正安全整数配置,默认值为 `3`。自主目标回合调用 `blocked` 时,插件会机械地要求至少已经接纳该数量的回合;配置值也会出现在模型指导中。运行时无法判断这些回合是否遇到了语义上相同的阻塞条件,因此语义等价性仍由模型判断。该计数特意与目标的宽裕继续执行上限分离。 + +## 测试 + +单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确实时智能体与驱动检查、根与子智能体权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、会话启动边沿后的重新激活、比较并交换与参数失败、准确目标回合的完成、可配置阻塞阈值,以及人类立即阻塞。无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实的目标、工具、循环和持久化插件,驱动脚本化模型工具调用,并从外部读取 JSONL,以验证模型可见的创建/暂停快照、结构化工具结果和配置后的提示词文本。 + +## 考虑过的替代方案 + +- **依赖提示词指令实施权限**——不予采纳,因为文本可以指导模型判断,却不能认证实时调用者、轮次或来源事件。 +- **把每个目标服务动词分别暴露为工具**——不予采纳,因为紧凑的读取/创建/更新表面可以降低模式成本,并保持统一的比较并交换行为。 +- **要求精确命令短语**——不予采纳,因为自然语言意图(包括英语以外的语言)应由模型解释;执行权限取决于来源,而不是拼写。 +- **根据持久的根或派生元数据授权**——不予采纳,因为成为独立恢复顶层会话的派生应接受新的人类权限,而当前仍受所有权约束的子智能体则不应接受。 +- **允许自主回合编辑或恢复目标**——不予采纳,因为继续执行权限比重新定义或重启人类目标的权限更窄。 +- **把阻塞阈值当作评估器**——不予采纳,因为事件计数无法证明障碍在语义上未改变或确实不可继续。 + +## 后果 + +- 模型获得稳定而紧凑的生命周期表面,无需直接访问目标服务。 +- 改变状态的调用同时受到实时运行时来源与持久比较并交换引用的约束。 +- 人类可以通过普通自然语言请求创建和重新激活目标,而恢复后的会话在收到此类输入前保持静止。 +- 目标回合可以完成或报告重复阻塞,但不能自行扩大任务权限。 +- 部署策略选择阻塞下限;同一个解析后的值同时控制执行与提示词指导。 + +## 已知限制与延期工作 + +- 是否属于重大目标、是否要求继续、目标是否完成以及阻塞条件是否相同,仍由模型进行语义分类。独立评估器或完成证书予以延期。 +- 这些工具会改变目标状态,但不调度目标回合、不分类异常驱动停止,也不取消活跃轮次;这些行为由同会话驱动器负责。 +- 面向人类的斜杠命令发现与渲染延期到命令表面层。 +- 若部署没有同时设定两个注册项的作用域,某个作用域可能隐藏工具注册,却保留独立注册的提示词段。 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index dfb97deef3..64fd5e692d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -21,6 +21,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | +| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `context/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | @@ -393,6 +394,94 @@ Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-searc glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. +## `@deepseek-ai/dsh-tool-goal` + +### `create_goal` + +Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. + +```json +{ + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default." + } + }, + "required": [ + "objective" + ] +} +``` + +Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/index.ts) + +### `get_goal` + +Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. + +```json +{ + "type": "object", + "properties": {} +} +``` + +Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/index.ts) + +### `update_goal` + +Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. + +```json +{ + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] +} +``` + +Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/index.ts) + +create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. + ## `@deepseek-ai/dsh-tool-skill` ### `skill` diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml b/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml new file mode 100644 index 0000000000..fa63160399 --- /dev/null +++ b/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml @@ -0,0 +1,26 @@ +# Test-only composition: drive all three goal tools through a real root agent. +- id: scripted-llm + name: './scripted-llm.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: goal + name: '@deepseek-ai/dsh-goal' + config: + defaultMaxGoalRounds: 11 + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + config: + blockedAfterConsecutiveRounds: 3 + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-demo' + config: + provider: goal-script + model: goal-script + persona: 'Execute the deterministic goal-tool composition test.' + welcome: 'goal-tools e2e ready.' + persistenceRoot: './.sessions' + workspaceContext: false diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts new file mode 100644 index 0000000000..39498f8e39 --- /dev/null +++ b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts @@ -0,0 +1,88 @@ +/** Deterministic adapter that creates, reads, then pauses one goal. */ + +import type { Context } from 'cordis' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm' + +interface GoalState { + readonly id: string + readonly revision: number +} + +/** Text from the latest ordinary user message, excluding raw goal-state context. */ +function latestPrompt(messages: readonly Message[]): { index: number; text: string } { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (message?.role !== 'user') continue + const text = message.content + .filter(block => block.type === 'text' && !block.text.startsWith('')) + .map(block => block.type === 'text' ? block.text : '') + .join('\n') + if (text.length > 0) return { index, text } + } + return { index: -1, text: '' } +} + +/** Parse the latest domain snapshot rendered into history. */ +function latestGoal(messages: readonly Message[]): GoalState | undefined { + for (const message of [...messages].reverse()) { + for (const block of [...message.content].reverse()) { + if (block.type !== 'text' || !block.text.startsWith('')) continue + const json = block.text.slice(''.length, -''.length) + const value = JSON.parse(json) as { goal?: GoalState } + if (value.goal !== undefined) return value.goal + } + } + return undefined +} + +/** Names of tool calls recorded after the latest ordinary prompt. */ +function callsAfter(messages: readonly Message[], index: number): string[] { + return messages.slice(index + 1).flatMap(message => message.content) + .filter(block => block.type === 'tool-call') + .map(block => block.type === 'tool-call' ? block.name : '') +} + +/** Emit one tool-call response. */ +async function* toolCall(name: string, args: object): AsyncIterable { + const id = CallId(`call-${name}`) + const raw = JSON.stringify(args) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 0, id, name, argumentsDelta: raw } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id, name, arguments: raw } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } +} + +/** Emit one terminal text response. */ +async function* textReply(text: string): AsyncIterable { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'finish', reason: { kind: 'stop' } } +} + +class GoalScriptAdapter extends LlmAdapter { + override stream(options: GenerateOptions): AsyncIterable { + const prompt = latestPrompt(options.messages) + const calls = callsAfter(options.messages, prompt.index) + if (prompt.text === 'start' && !calls.includes('create_goal')) { + return toolCall('create_goal', { objective: 'Finish the composed goal-tool proof', max_goal_rounds: 7 }) + } + if (prompt.text === 'start' && !calls.includes('get_goal')) return toolCall('get_goal', {}) + if (prompt.text === 'start') return textReply('GOAL CREATED') + if (prompt.text === 'pause' && !calls.includes('update_goal')) { + const goal = latestGoal(options.messages) + if (goal === undefined) throw new Error('scripted goal state missing') + return toolCall('update_goal', { goal_id: goal.id, revision: goal.revision, action: 'pause' }) + } + if (prompt.text === 'pause') return textReply('GOAL PAUSED') + return textReply('UNEXPECTED PROMPT') + } +} + +export const name = 'goal-tool-scripted-llm' +export const inject = ['llm'] + +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['goal-script'], new GoalScriptAdapter()) +} diff --git a/examples/package.json b/examples/package.json index 3a3815582a..b1c8ac52cd 100644 --- a/examples/package.json +++ b/examples/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", + "@deepseek-ai/dsh-tool-goal": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", diff --git a/knip.json b/knip.json index 6f04363494..eb4eff3a9f 100644 --- a/knip.json +++ b/knip.json @@ -11,6 +11,7 @@ "entry": [ "echo-agent/src/*.ts", "echo-agent/tests/fixtures/goal/goal/seed-goal.ts", + "echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts", "headless-agent/tests/fixtures/cli-mock-llm.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", @@ -71,6 +72,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/goal/tool-goal": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/code-runtime/code-runtime-worker": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 3097f1abeb..c6b7f9a804 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'get_goal', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/goal/README.md b/packages/goal/README.md index 45f1b4f135..1ab912b3c7 100644 --- a/packages/goal/README.md +++ b/packages/goal/README.md @@ -5,5 +5,6 @@ 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` | +| `tool-goal/` | Model-facing read/create/update tools with execution-time authority checks | — | 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. diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md new file mode 100644 index 0000000000..86def6b65e --- /dev/null +++ b/packages/goal/tool-goal/README.md @@ -0,0 +1,55 @@ +# @deepseek-ai/dsh-tool-goal + +The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal`, `create_goal`, and `update_goal`. The [goal-tool RFC](../../../docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md) owns the authority split and Codex-shaped UX. + +## Tools + +- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, and current process-local activation. +- `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution. +- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`. + +All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. + +## Authority + +Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does. + +Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted. Direct human authority may stop a goal immediately. + +## Config + +```yaml +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + config: + blockedAfterConsecutiveRounds: 3 +``` + +The value must be a positive safe integer. It supplies both the hard lower bound on model self-blocking and the number named in model guidance. + +## Model Experience + +### System prompt + +**What the model sees**: A fixed goal policy says when semantic human intent warrants creation, requires exact read-before-update refs, explains rearming after resume/fork, and limits completion/blocking claims. The configured threshold is interpolated into that guidance. + +**Token effect**: Small fixed input cost on every request where this plugin's prompt registration is in scope. + +#### Goal policy + +```markdown +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +``` + +### Tool schemas and results + +**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 `` snapshot after the tool batch. `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. + +## Known Limitations and Deferred Work + +- **Semantic intent remains model judgment** — execution can prove direct human provenance, not whether a request is substantial enough to merit a goal. +- **Same-condition blocking remains model judgment** — the runtime enforces distinct admitted-round count, not semantic equivalence of obstacles; an independent evaluator is deferred. +- **No scheduling or UI commands** — these tools mutate state only; the same-session driver and human command surfaces are separate stack layers. +- **Prompt registration is independent of filtering** — a scope may hide the tools while retaining their guidance unless the deployment scopes both registrations together. diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json new file mode 100644 index 0000000000..28cd6839f8 --- /dev/null +++ b/packages/goal/tool-goal/package.json @@ -0,0 +1,47 @@ +{ + "name": "@deepseek-ai/dsh-tool-goal", + "description": "Model-facing same-session goal tools with execution-time authority checks", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-goal": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts new file mode 100644 index 0000000000..a2238aab9c --- /dev/null +++ b/packages/goal/tool-goal/src/authority.ts @@ -0,0 +1,105 @@ +/** Execution-time authority checks for the model-facing goal tools. */ + +import type { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { GoalView } from '@deepseek-ai/dsh-goal' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' + +type TurnStartEvent = Extract + +/** Current open turn plus the events accepted after its start boundary. */ +export interface GoalToolExecution { + readonly agent: Agent + readonly start: TurnStartEvent + readonly events: readonly SessionEvent[] +} + +/** Hard authority granted to one state-changing call. */ +export type GoalToolAuthority = + | { readonly kind: 'direct-human' } + | { readonly kind: 'goal-round'; readonly goal: GoalView } + +/** Throw one structured tool-policy failure. */ +function reject(message: string, code = 'GOAL_TOOL_AUTHORITY_REQUIRED'): never { + throw new HarnessError(message, code) +} + +/** Locate the open turn enclosing a model tool call. */ +function openTurn(agent: Agent): { start: TurnStartEvent; events: readonly SessionEvent[] } { + const events = agent.session.events + for (let index = events.length - 1; index >= 0; index -= 1) { + const boundary = events[index] + if (boundary?.type === 'turn/end') { + reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') + } + if (boundary?.type === 'turn/start') { + return { start: boundary, events: events.slice(index + 1) } + } + } + return reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') +} + +/** + * Resolve and authenticate the calling agent and its driver boundary. + * @param ctx - Context carrying the live agent registry. + * @param exec - Tool execution metadata supplied by the registry. + * @returns The authenticated agent and its current turn window. + */ +export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolExecution { + const agent = exec.agent + if (agent === undefined) { + return reject('goal tools require a calling agent', 'GOAL_TOOL_AGENT_REQUIRED') + } + if (ctx.agents.get(agent.id) !== agent || agent.status !== 'running' + || ctx.agents.currentInitiator() !== agent) { + return reject( + 'goal tools require the exact live calling agent inside its active driver', + 'GOAL_TOOL_DRIVER_REQUIRED', + ) + } + return { agent, ...openTurn(agent) } +} + +/** Whether an accepted human message appears in the current root-agent turn. */ +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.type === 'steering/message') + && event.data.source.kind === 'user') +} + +/** Whether this turn is the current goal's exact admitted round. */ +function isMatchingGoalRound(execution: GoalToolExecution, goal: GoalView): boolean { + return execution.events.some(event => event.type === 'user/message' + && event.data.source.kind === 'goal' + && event.data.source.goalId === goal.id + && event.data.source.revision === goal.revision + && event.data.source.round === goal.roundsStarted) +} + +/** + * Require authority originating in a human message accepted by a runtime root. + * @param ctx - Context carrying the live agent graph. + * @param execution - Authenticated current tool execution. + */ +export function requireDirectHuman(ctx: Context, execution: GoalToolExecution): void { + if (hasDirectHumanInput(ctx, execution)) return + reject('this goal operation requires a direct human turn on a top-level agent') +} + +/** + * Resolve completion authority from either direct human input or the exact goal round. + * @param ctx - Context carrying live agents and goal state. + * @param execution - Authenticated current tool execution. + * @returns The direct-human or exact-goal-round authority grant. + */ +export function completionAuthority(ctx: Context, execution: GoalToolExecution): GoalToolAuthority { + if (hasDirectHumanInput(ctx, execution)) return { kind: 'direct-human' } + const goal = ctx.goals.get(execution.agent) + if (goal !== undefined && isMatchingGoalRound(execution, goal)) { + return { kind: 'goal-round', goal } + } + return reject('complete and blocked require a direct human turn or the current goal round') +} diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts new file mode 100644 index 0000000000..9bfa51095f --- /dev/null +++ b/packages/goal/tool-goal/src/index.ts @@ -0,0 +1,222 @@ +/** + * Model-facing `get_goal`, `create_goal`, and `update_goal` tools over the + * persisted same-session goal domain. + * @module @deepseek-ai/dsh-tool-goal + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { GoalId } from '@deepseek-ai/dsh-goal' +import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { + completionAuthority, + goalToolExecution, + requireDirectHuman, +} from './authority.ts' + +export const name = 'tool-goal' +export const inject = ['agents', 'goals', 'tools', 'systemPrompt'] + +/** Model policy and hard lower bounds for goal-state updates. */ +export interface Config { + /** Minimum admitted goal rounds before the model may self-report `blocked`. */ + blockedAfterConsecutiveRounds?: number +} + +/** Schemastery config for the goal-tool policy. */ +export const Config: z = z.object({ + blockedAfterConsecutiveRounds: z.number().step(1).min(1).default(3), +}) + +/** Fully materialized tool policy. */ +interface ResolvedConfig { + readonly blockedAfterConsecutiveRounds: number +} + +type UpdateAction = 'edit' | 'pause' | 'resume' | 'complete' | 'blocked' + +const UPDATE_ACTIONS: UpdateAction[] = ['edit', 'pause', 'resume', 'complete', 'blocked'] + +const CREATE_DESCRIPTION = + 'Create one persisted same-session completion goal when the current direct human request ' + + 'is a long-running objective that should continue across autonomous goal rounds. You may ' + + 'infer that intent without requiring the user to say "create a goal". Do not use this for ' + + 'trivial single-turn work. Execution rejects non-human and subagent authority.' + +const GET_DESCRIPTION = + 'Read the current same-session goal, including its exact id/revision, durable phase, admitted ' + + 'round count, cap, and live process-local activation. Call this before updating a goal.' + +/** Render policy guidance with its deployment-selected blocked threshold. */ +function guidance(blockedAfter: number): string { + return 'Use goal tools for one long-running completion objective in the current session. ' + + 'create_goal may infer goal intent from a direct human request in any language; do not ' + + 'create a goal for routine single-turn work. Call get_goal before update_goal and copy its ' + + 'exact goal_id and revision. After session resume or fork, an active goal is disarmed: when ' + + 'a human asks to continue or resume in any wording or language, use update_goal action ' + + 'resume to rearm it. Mark complete only when the objective is actually achieved. Mark ' + + `blocked only after the same blocking condition persists for at least ${blockedAfter} ` + + 'consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked.' +} + +/** Validate config even when apply is called directly outside Loader normalization. */ +function resolveConfig(config: Config): ResolvedConfig { + const blockedAfter = config.blockedAfterConsecutiveRounds ?? 3 + if (!Number.isSafeInteger(blockedAfter) || blockedAfter < 1) { + throw new TypeError('blockedAfterConsecutiveRounds must be a positive safe integer') + } + return { blockedAfterConsecutiveRounds: blockedAfter } +} + +/** Build the exact compare-and-set ref from model arguments. */ +function goalRef(goalId: string, revision: number): GoalRef { + if (goalId.length === 0 || goalId !== goalId.trim() + || !Number.isSafeInteger(revision) || revision < 1) { + throw new HarnessError( + 'goal_id must be non-empty and revision must be a positive safe integer', + 'GOAL_TOOL_INVALID_UPDATE', + ) + } + return { id: GoalId(goalId), revision } +} + +/** Stable compact model result; activation is an observation, not replay state. */ +function renderGoal(goal: GoalView | undefined): string { + if (goal === undefined) return JSON.stringify({ goal: null }) + return JSON.stringify({ + goal: { + id: goal.id, + revision: goal.revision, + objective: goal.objective, + phase: goal.phase, + roundsStarted: goal.roundsStarted, + maxGoalRounds: goal.maxGoalRounds, + }, + activation: goal.activation, + }) +} + +/** Generic, args-only pending presentation shared by the goal tools. */ +function present(title: string, kind: 'read' | 'other', rawInput?: unknown): GenericCallView { + return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } } +} + +/** Register the three Codex-shaped goal tools and their shared policy section. */ +export function apply(ctx: Context, config: Config): void { + const resolved = resolveConfig(config) + ctx.systemPrompt.section({ + name: 'tool:goal', + order: 114, + text: guidance(resolved.blockedAfterConsecutiveRounds), + }) + + ctx.tools.register(defineTool({ + name: 'get_goal', + description: GET_DESCRIPTION, + parameters: {}, + execute(_args, exec) { + const execution = goalToolExecution(ctx, exec) + return Promise.resolve([{ + type: 'text', + text: renderGoal(ctx.goals.get(execution.agent)), + }]) + }, + presentCall: () => present('Read current goal', 'read'), + })) + + ctx.tools.register(defineTool({ + name: 'create_goal', + description: CREATE_DESCRIPTION, + parameters: { + objective: { + type: 'string', + required: true, + description: 'The concrete completion objective inferred from the direct human request.', + }, + max_goal_rounds: { + type: 'number', + description: 'Optional positive safe-integer cap; omission uses the goal-domain deployment default.', + }, + }, + execute(args, exec) { + const execution = goalToolExecution(ctx, exec) + requireDirectHuman(ctx, execution) + const goal = ctx.goals.create(execution.agent, { + objective: args.objective, + ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, + }) + return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + }, + presentCall: args => present('Create goal', 'other', args.objective), + })) + + ctx.tools.register(defineTool({ + name: 'update_goal', + description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' + + 'top-level human turn. complete and blocked additionally accept the exact admitted goal ' + + 'round. blocked is rejected before the configured minimum round count; the model remains ' + + 'responsible for judging that the same condition persisted across those rounds.', + parameters: { + goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' }, + revision: { type: 'number', required: true, description: 'Exact positive revision returned by get_goal.' }, + action: { + type: 'string', + required: true, + enum: UPDATE_ACTIONS, + description: 'edit | pause | resume | complete | blocked', + }, + objective: { type: 'string', description: 'Replacement objective; valid only with action edit.' }, + max_goal_rounds: { type: 'number', description: 'Replacement cap; valid only with action edit.' }, + }, + execute(args, exec) { + const execution = goalToolExecution(ctx, exec) + const ref = goalRef(args.goal_id, args.revision) + const replacements = { + ...args.objective === undefined ? {} : { objective: args.objective }, + ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, + } + if (args.action === 'edit') { + requireDirectHuman(ctx, execution) + return Promise.resolve([{ + type: 'text', + text: renderGoal(ctx.goals.edit(execution.agent, ref, replacements)), + }]) + } + if (args.objective !== undefined || args.max_goal_rounds !== undefined) { + throw new HarnessError( + 'objective and max_goal_rounds are valid only with action edit', + 'GOAL_TOOL_INVALID_UPDATE', + ) + } + if (args.action === 'pause' || args.action === 'resume') { + requireDirectHuman(ctx, execution) + const goal = args.action === 'pause' + ? ctx.goals.pause(execution.agent, ref) + : ctx.goals.resume(execution.agent, ref) + return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + } + const authority = completionAuthority(ctx, execution) + if (args.action === 'blocked' && authority.kind === 'goal-round' + && authority.goal.roundsStarted < resolved.blockedAfterConsecutiveRounds) { + throw new HarnessError( + `blocked requires at least ${resolved.blockedAfterConsecutiveRounds} consecutive goal rounds; ` + + `current round is ${authority.goal.roundsStarted}`, + 'GOAL_TOOL_BLOCK_THRESHOLD', + ) + } + const goal = args.action === 'complete' + ? ctx.goals.complete(execution.agent, ref) + : ctx.goals.block(execution.agent, ref) + return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + }, + presentCall: args => present( + `${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`, + 'other', + args.objective ?? args.goal_id, + ), + })) +} diff --git a/packages/goal/tool-goal/tests/tool-goal.e2e.ts b/packages/goal/tool-goal/tests/tool-goal.e2e.ts new file mode 100644 index 0000000000..037b91d18d --- /dev/null +++ b/packages/goal/tool-goal/tests/tool-goal.e2e.ts @@ -0,0 +1,124 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { decodeGoalChange } from '@deepseek-ai/dsh-goal' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + +const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL( + '../../../../examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) +const PROCESS_TIMEOUT_MS = 30_000 +const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 + +let child: ChildProcessWithoutNullStreams | undefined +let workdir: string | undefined + +afterEach(async () => { + if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') + child = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +async function runComposition(): Promise<{ stdout: string; stderr: string }> { + workdir = await mkdtemp(join(tmpdir(), 'goal-tools-e2e-')) + const cwd = workdir + return new Promise((resolve, reject) => { + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: [configPath], + tsconfigPath: repoTsconfig, + exposeInternals: true, + env: { + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + }) + const proc = spawn(launch.command, launch.args, { + cwd, + env: { ...process.env, ...launch.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + child = proc + let stdout = '' + let stderr = '' + let pauseSent = false + let inputClosed = false + proc.stdout.setEncoding('utf8') + proc.stdout.on('data', (chunk: string) => { + stdout += chunk + if (!pauseSent && stdout.includes('GOAL CREATED') && stdout.includes('\n> ')) { + pauseSent = true + proc.stdin.write('pause\n') + } + if (!inputClosed && stdout.includes('GOAL PAUSED')) { + inputClosed = true + proc.stdin.end() + } + }) + proc.stderr.setEncoding('utf8') + proc.stderr.on('data', (chunk: string) => { stderr += chunk }) + + const timer = setTimeout(() => { + proc.kill('SIGKILL') + reject(new Error( + `goal-tools e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`, + )) + }, PROCESS_TIMEOUT_MS) + proc.on('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve({ stdout, stderr }) + else reject(new Error(`goal-tools e2e exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + proc.on('error', (error) => { clearTimeout(timer); reject(error) }) + proc.stdin.write('start\n') + }) +} + +describe('goal tools through a real Loader, app, and stdio process', () => { + it('creates, reads, and pauses one root goal with durable tool and state records', async () => { + const { stdout, stderr } = await runComposition() + expect(stderr).not.toContain('UNHANDLED') + expect(stdout).toContain('goal-tools e2e ready.') + expect(stdout).toContain('GOAL CREATED') + expect(stdout).toContain('GOAL PAUSED') + + const logs = await jsonlFiles(join(workdir as string, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) + const calls = events.filter(event => event.type === 'tool/call') + expect(calls.map(event => event.data.name)).toEqual(['create_goal', 'get_goal', 'update_goal']) + const results = events.filter(event => event.type === 'tool/result') + expect(results).toHaveLength(3) + expect(results.every(event => !event.data.isError)).toBe(true) + + const changes = events + .filter(event => event.type === 'context/message' && event.data.source.kind === 'goal') + .map(event => event.type === 'context/message' ? decodeGoalChange(event.data.meta) : undefined) + expect(changes.map(change => change?.operation)).toEqual(['create', 'pause']) + expect(changes[1]).toMatchObject({ goal: { phase: 'paused', revision: 2, maxGoalRounds: 7 } }) + expect(JSON.stringify(changes)).not.toContain('activation') + + const headers = events.filter(event => event.type === 'request/header') + expect(JSON.stringify(headers)).toContain('infer goal intent') + expect(JSON.stringify(headers)).toContain('at least 3 consecutive goal rounds') + }, TEST_TIMEOUT_MS) +}) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts new file mode 100644 index 0000000000..c5f1a00593 --- /dev/null +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -0,0 +1,374 @@ +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 type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' +import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' +import type { GoalRef } from '@deepseek-ai/dsh-goal' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import * as toolGoal from '@deepseek-ai/dsh-tool-goal' + +interface StubAgent { + readonly agent: Agent + readonly session: Session + setStatus(status: AgentStatus): void +} + +/** Build one registry-compatible live agent whose injections append in place. */ +function stubAgent(rawId: string): StubAgent { + const session = new Session(SessionId(rawId)) + let status: AgentStatus = 'running' + const agent: Agent = { + id: session.id, + options: {}, + session, + get status() { return status }, + ctx: new Context(), + send() {}, + steer() {}, + inject(content: ContentBlock[], options?: InjectOptions) { + const source = options?.source ?? { kind: 'user' } + session.append('context/message', { + content, + source, + ...options?.envelope === undefined ? {} : { envelope: options.envelope }, + ...options?.meta === undefined ? {} : { meta: options.meta }, + }, { surfaceOp: 'append' }) + }, + cancel() {}, + whenIdle() { return Promise.resolve() }, + } + return { agent, session, setStatus(value) { status = value } } +} + +/** Open one message-triggered turn with its accepted model-visible input. */ +function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): number { + 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', { + content: [{ type: 'text', text }], + source, + }, { surfaceOp: 'append' }) + return turn +} + +/** Close the currently open test turn. */ +function closeTurn(stub: StubAgent, turn: number): void { + stub.session.append('turn/end', { turn, reason: { kind: 'completed' } }) +} + +async function harness(config: toolGoal.Config = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(AgentRegistry) + await ctx.plugin(ToolRegistry) + await ctx.plugin(GoalService) + const fiber = await ctx.plugin(toolGoal, config) + const root = stubAgent(`goal-tool-root-${Math.random()}`) + ctx.agents.register(root.agent) + return { ctx, fiber, root } +} + +/** Execute one registered tool under an optional driver initiator. */ +async function execute( + ctx: Context, + name: string, + args: unknown, + agent?: Agent, + initiator: Agent | undefined = agent, +): Promise { + const run = () => ctx.tools.execute({ + callId: CallId(`call-${Math.random()}`), + name, + arguments: args, + ...agent === undefined ? {} : { agent }, + }) + return initiator === undefined ? run() : ctx.agents.withInitiator(initiator, run) +} + +/** Parse the compact JSON returned by a successful goal tool. */ +function resultJson(result: ToolExecutionResult): Record { + expect(result.isError).toBe(false) + const block = result.content[0] + if (block?.type !== 'text') throw new Error('expected text tool result') + return JSON.parse(block.text) as Record +} + +/** Read the returned goal sub-object. */ +function resultGoal(result: ToolExecutionResult): Record { + const goal = resultJson(result)['goal'] + if (typeof goal !== 'object' || goal === null) throw new Error('expected returned goal') + return goal as Record +} + +describe('goal tool registration and presentation', () => { + it('registers three exclusive tools plus configured guidance and disposes all contributions', async () => { + const { ctx, fiber } = await harness({ blockedAfterConsecutiveRounds: 5 }) + expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name)) + .toEqual(['create_goal', 'get_goal', 'update_goal']) + for (const name of ['create_goal', 'get_goal', 'update_goal']) { + expect(ctx.tools.executionMode({ callId: CallId(name), name, arguments: {} })) + .toEqual({ kind: 'exclusive' }) + } + const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal') + expect(section?.text).toContain('infer goal intent') + expect(section?.text).toContain('at least 5 consecutive goal rounds') + + await fiber.dispose() + expect(ctx.tools.get('get_goal')).toBeUndefined() + expect((await ctx.systemPrompt.assemble()).sections.some(item => item.name === 'tool:goal')).toBe(false) + }) + + it('uses args-only generic render intent and soft-fails malformed replay args', async () => { + const { ctx } = await harness() + expect(ctx.tools.get('get_goal')?.presentCall?.({})).toEqual({ + card: 'generic', title: 'Read current goal', kind: 'read', + }) + expect(ctx.tools.get('create_goal')?.presentCall?.({ objective: 'ship' })).toEqual({ + card: 'generic', title: 'Create goal', kind: 'other', rawInput: 'ship', + }) + expect(ctx.tools.get('update_goal')?.presentCall?.({ + goal_id: 'goal-1', revision: 2, action: 'blocked', + })).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'goal-1' }) + expect(ctx.tools.get('update_goal')?.presentCall?.({ + goal_id: 'goal-1', revision: 2, action: 'resume', + })).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' }) + expect(ctx.tools.get('update_goal')?.presentCall?.({ wrong: true })).toBeUndefined() + }) + + it('has the Loader-safe namespace export shape', () => { + expect('default' in toolGoal).toBe(false) + expect(toolGoal.name).toBe('tool-goal') + expect(toolGoal.inject).toEqual(['agents', 'goals', 'tools', 'systemPrompt']) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(toolGoal)).toBe(toolGoal) + }) + + it('fails invalid direct config before registering anything', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(AgentRegistry) + await ctx.plugin(ToolRegistry) + await ctx.plugin(GoalService) + expect(() => { + toolGoal.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 }) + }).toThrow( + 'blockedAfterConsecutiveRounds must be a positive safe integer', + ) + expect(ctx.tools.get('get_goal')).toBeUndefined() + }) + + it('resolves the direct-apply default before registration', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(AgentRegistry) + await ctx.plugin(ToolRegistry) + await ctx.plugin(GoalService) + toolGoal.apply(ctx, {}) + const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal') + expect(section?.text).toContain('at least 3 consecutive goal rounds') + }) +}) + +describe('goal tool execution authority', () => { + it('lets a root model infer create intent from its accepted human turn', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }, '请持续工作直到这个功能完成') + const result = await execute(ctx, 'create_goal', { + objective: 'Finish the feature', max_goal_rounds: 9, + }, root.agent) + expect(resultGoal(result)).toMatchObject({ + objective: 'Finish the feature', revision: 1, phase: 'active', maxGoalRounds: 9, + }) + expect(resultJson(result)['activation']).toBe('armed') + expect(ctx.goals.get(root.agent)?.objective).toBe('Finish the feature') + }) + + it('rejects agentless, driverless, non-human, and live-child creation', async () => { + const { ctx, root } = await harness() + const agentless = await execute(ctx, 'get_goal', {}) + expect(agentless.error?.code).toBe('GOAL_TOOL_AGENT_REQUIRED') + + openTurn(root, { kind: 'user' }) + const driverless = await ctx.tools.execute({ + callId: CallId('call-driverless'), + name: 'get_goal', + arguments: {}, + agent: root.agent, + }) + expect(driverless.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + closeTurn(root, 1) + + openTurn(root, { kind: 'plugin', plugin: 'test' }) + const nonHuman = await execute(ctx, 'create_goal', { objective: 'forged' }, root.agent) + expect(nonHuman.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + closeTurn(root, 2) + + const child = stubAgent('goal-tool-child') + ctx.agents.enter(child.agent, root.agent) + ctx.agents.announce(child.agent) + openTurn(child, { kind: 'user' }) + const childResult = await execute(ctx, 'create_goal', { objective: 'child goal' }, child.agent) + expect(childResult.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + }) + + it('rejects calls before a turn and after its end boundary', async () => { + const { ctx, root } = await harness() + const before = await execute(ctx, 'get_goal', {}, root.agent) + expect(before.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + + const turn = openTurn(root, { kind: 'user' }) + closeTurn(root, turn) + const after = await execute(ctx, 'get_goal', {}, root.agent) + expect(after.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + }) + + it('rejects terminal reporting without human input or a current goal round', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'plugin', plugin: 'test' }) + const result = await execute(ctx, 'update_goal', { + goal_id: 'goal-missing', revision: 1, action: 'complete', + }, root.agent) + expect(result.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + }) + + it('accepts direct human steering in a goal-sourced root turn', async () => { + const { ctx, root } = await harness() + const humanTurn = openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'steer me' }) + closeTurn(root, humanTurn) + const round = openTurn(root, { + kind: 'goal', goalId: created.id, revision: created.revision, round: 1, + }) + root.session.append('steering/message', { + turn: round, + 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) + expect(resultGoal(paused)).toMatchObject({ phase: 'paused', revision: 2 }) + }) + + it('rejects an initiator different from exec.agent', async () => { + const { ctx, root } = await harness() + const other = stubAgent('goal-tool-other') + ctx.agents.register(other.agent) + openTurn(other, { kind: 'user' }) + const result = await execute(ctx, 'get_goal', {}, other.agent, root.agent) + expect(result.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + }) +}) + +describe('goal tool state transitions', () => { + it('reads null, then edits, pauses, and resumes by exact revision in one human turn', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + expect(resultJson(await execute(ctx, 'get_goal', {}, root.agent))).toEqual({ goal: null }) + let goal = resultGoal(await execute(ctx, 'create_goal', { objective: 'old' }, root.agent)) + goal = resultGoal(await execute(ctx, 'update_goal', { + goal_id: goal['id'], revision: goal['revision'], action: 'edit', + objective: 'new', max_goal_rounds: 8, + }, root.agent)) + expect(goal).toMatchObject({ objective: 'new', revision: 2, maxGoalRounds: 8 }) + goal = resultGoal(await execute(ctx, 'update_goal', { + goal_id: goal['id'], revision: goal['revision'], action: 'pause', + }, root.agent)) + expect(goal).toMatchObject({ phase: 'paused', revision: 3 }) + goal = resultGoal(await execute(ctx, 'update_goal', { + goal_id: goal['id'], revision: goal['revision'], action: 'resume', + }, root.agent)) + expect(goal).toMatchObject({ phase: 'active', revision: 4 }) + }) + + it('rearms a restored active goal only after a new direct human prompt', async () => { + const { ctx, root } = await harness() + let turn = openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'continue later' }) + closeTurn(root, turn) + agentEvents(ctx, root.agent).emit('agent/session-start', 'resume') + expect(ctx.goals.get(root.agent)?.activation).toBe('disarmed') + turn = openTurn(root, { kind: 'user' }, '继续') + const resumed = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'resume', + }, root.agent) + expect(resultGoal(resumed)).toMatchObject({ phase: 'active', revision: 2 }) + expect(resultJson(resumed)['activation']).toBe('armed') + closeTurn(root, turn) + }) + + it('returns structured domain and conditional-argument failures', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + const invalidCreate = await execute(ctx, 'create_goal', { objective: ' ' }, root.agent) + expect(invalidCreate.error?.code).toBe('GOAL_INVALID_OBJECTIVE') + const created = ctx.goals.create(root.agent, { objective: 'valid' }) + const replacement = await execute(ctx, 'update_goal', { + goal_id: created.id, + revision: created.revision, + action: 'pause', + objective: 'not valid for pause', + }, root.agent) + expect(replacement.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const malformedRef = await execute(ctx, 'update_goal', { + goal_id: '', revision: 0, action: 'edit', objective: 'x', + }, root.agent) + expect(malformedRef.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + }) + + it('allows exact goal rounds to complete but not edit or pause', async () => { + const { ctx, root } = await harness() + const humanTurn = openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'round-owned' }) + closeTurn(root, humanTurn) + openTurn(root, { kind: 'goal', goalId: created.id, revision: created.revision, round: 1 }) + const edit = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'edit', objective: 'forbidden', + }, root.agent) + expect(edit.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + const complete = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'complete', + }, root.agent) + expect(resultGoal(complete)).toMatchObject({ phase: 'complete', revision: 2, roundsStarted: 1 }) + }) + + it('enforces the configured model self-block lower bound across admitted rounds', async () => { + const { ctx, root } = await harness({ blockedAfterConsecutiveRounds: 3 }) + let turn = openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'blocked eventually' }) + closeTurn(root, turn) + const ref: GoalRef = { id: GoalId(created.id), revision: created.revision } + + for (let round = 1; round <= 2; round += 1) { + turn = openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round }) + const result = await execute(ctx, 'update_goal', { + goal_id: ref.id, revision: ref.revision, action: 'blocked', + }, root.agent) + expect(result.error?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD') + closeTurn(root, turn) + } + openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 3 }) + const blocked = await execute(ctx, 'update_goal', { + goal_id: ref.id, revision: ref.revision, action: 'blocked', + }, root.agent) + expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked', roundsStarted: 3 }) + }) + + it('lets direct human authority block before the model threshold', async () => { + const { ctx, root } = await harness({ blockedAfterConsecutiveRounds: 9 }) + openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'human stop' }) + const blocked = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'blocked', + }, root.agent) + expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked', roundsStarted: 0 }) + }) +}) diff --git a/packages/goal/tool-goal/tsconfig.json b/packages/goal/tool-goal/tsconfig.json new file mode 100644 index 0000000000..d5c34d4e29 --- /dev/null +++ b/packages/goal/tool-goal/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../goal" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f1fa58dce..e1d3bd1fe7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,6 +185,9 @@ importers: '@deepseek-ai/dsh-tool-fs-search': specifier: workspace:* version: link:../packages/fs/tool-fs-search + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:* + version: link:../packages/goal/tool-goal '@deepseek-ai/dsh-tool-subagent': specifier: workspace:* version: link:../packages/subagent/tool-subagent @@ -1007,6 +1010,40 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/goal/tool-goal: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../goal + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + packages/guard/repeat-tool-guard: dependencies: schemastery: diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 8606d30895..1921dbf27c 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -10,6 +10,8 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs' import { basename, resolve } from 'node:path' import { Context } from 'cordis' import type { ToolSchema } from '@deepseek-ai/dsh-llm' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import GoalService from '@deepseek-ai/dsh-goal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' @@ -28,6 +30,7 @@ import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' +import * as ToolGoal from '@deepseek-ai/dsh-tool-goal' import * as ToolSkill from '@deepseek-ai/dsh-tool-skill' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' @@ -178,6 +181,20 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', }, + { + pkg: '@deepseek-ai/dsh-tool-goal', + dir: 'tool-goal', + source: 'packages/goal/tool-goal/src/index.ts', + requires: ['ctx.tools', 'ctx.agents', 'ctx.goals', 'ctx.systemPrompt', 'a calling Agent in an authorized open turn'], + writes: ['tool/call', 'context/message goal snapshot for mutations', 'tool/result'], + async mount(ctx) { + await ctx.plugin(AgentRegistry) + await ctx.plugin(GoalService) + await ctx.plugin(ToolGoal) + }, + note: + 'create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds.', + }, { pkg: '@deepseek-ai/dsh-tool-skill', dir: 'tool-skill', diff --git a/tsconfig.build.json b/tsconfig.build.json index 668541ade3..db84df1542 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -26,6 +26,7 @@ { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/goal/goal" }, + { "path": "./packages/goal/tool-goal" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, diff --git a/tsconfig.json b/tsconfig.json index 156792259e..27b0e1d184 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,6 +37,7 @@ { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/goal/goal" }, + { "path": "./packages/goal/tool-goal" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, From fbefc1d65b3cc20936bb4fd0a079e5a41a8315d7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:42:17 +0800 Subject: [PATCH 02/11] fix(goal): stop terminal goal tool turns --- docs/config-catalog.md | 2 +- docs/event-producer-consumer.md | 2 +- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +-- .../2026-07-19-model-facing-goal-tools.md | 2 ++ .../2026-07-19-model-facing-goal-tools.zh.md | 2 ++ packages/goal/tool-goal/README.md | 2 ++ packages/goal/tool-goal/src/index.ts | 28 ++++++++++++++++++- .../goal/tool-goal/tests/tool-goal.spec.ts | 13 +++++++++ 8 files changed, 50 insertions(+), 5 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8f95bad05b..64cbb27222 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1110,7 +1110,7 @@ export interface Config { } ``` -Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/src/index.ts) +Source: [`packages/goal/tool-goal/src/index.ts:27`](../packages/goal/tool-goal/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 8f1fd333fe..d48349cdf0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,7 +22,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index 0d66c0c360..e4e0fbf855 100644 --- a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: 7a207bbc73e13ca5111d73ee2c58cf05fbe6387e -2026-07-19-model-facing-goal-tools.zh.md: 46ad7671e1ef2a6856df6d1ec893896aab9760b5 +2026-07-19-model-facing-goal-tools.md: cb823aa944d69228005884ac73cc99b67fa00dfb +2026-07-19-model-facing-goal-tools.zh.md: f93ffa3a2eed602d8c7d98faccf09c9e46924f28 diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md index 7a207bbc73..cb823aa944 100644 --- a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -22,6 +22,8 @@ The prompt tells the model that it may infer goal intent from a direct human req All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state. +A successful update that leaves the goal stopped contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request after pause, block, or completion. A later successful resume in the same turn removes that contribution. + ### Execution authority Every call requires an `exec.agent` that is the exact running object in `AgentRegistry`, is the current inherited driver initiator, and has an open turn. These are execution-time checks and cannot be bypassed by prompt injection or hand-authored tool arguments. diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index 46ad7671e1..f93ffa3a2e 100644 --- a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -22,6 +22,8 @@ Status: implemented 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。 +成功更新后若目标处于停止状态,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免在暂停、阻塞或完成后再发起一次不必要的模型请求。同一轮次中后续成功的恢复会移除该贡献。 + ### 执行权限 每次调用都要求存在 `exec.agent`,且它必须是 `AgentRegistry` 中完全相同的运行中对象、当前继承的驱动发起者,并处于开放轮次内。这些检查在执行时进行,不能通过提示词注入或手写工具参数绕过。 diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 86def6b65e..a3987e32e1 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -10,6 +10,8 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. +A successful mutation that leaves the goal stopped contributes the existing terminal `agent/turn-stop` decision for that physical turn. A later same-turn resume clears the contribution. This avoids an extra model request after pause, block, or completion without changing ordinary loop continuation. + ## Authority Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does. diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 9bfa51095f..29068a4afa 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -6,6 +6,7 @@ import type { Context } from 'cordis' import z from 'schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' import { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' import { HarnessError } from '@deepseek-ai/dsh-llm' @@ -17,6 +18,7 @@ import { goalToolExecution, requireDirectHuman, } from './authority.ts' +import type { GoalToolExecution } from './authority.ts' export const name = 'tool-goal' export const inject = ['agents', 'goals', 'tools', 'systemPrompt'] @@ -105,9 +107,28 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } } } +/** Remember whether one successful mutation makes this turn terminal. */ +function observeMutation( + terminalTurns: WeakMap, + execution: GoalToolExecution, + goal: GoalView, +): void { + if (goal.phase === 'active' && goal.activation === 'armed') { + terminalTurns.delete(execution.agent) + return + } + terminalTurns.set(execution.agent, execution.start.data.turn) +} + /** Register the three Codex-shaped goal tools and their shared policy section. */ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) + const terminalTurns = new WeakMap() + ctx.on('agent/turn-stop', (agent, turn) => { + if (terminalTurns.get(agent) !== turn) return undefined + terminalTurns.delete(agent) + return { action: 'stop' } + }) ctx.systemPrompt.section({ name: 'tool:goal', order: 114, @@ -149,6 +170,7 @@ export function apply(ctx: Context, config: Config): void { objective: args.objective, ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, }) + observeMutation(terminalTurns, execution, goal) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present('Create goal', 'other', args.objective), @@ -181,9 +203,11 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'edit') { requireDirectHuman(ctx, execution) + const goal = ctx.goals.edit(execution.agent, ref, replacements) + observeMutation(terminalTurns, execution, goal) return Promise.resolve([{ type: 'text', - text: renderGoal(ctx.goals.edit(execution.agent, ref, replacements)), + text: renderGoal(goal), }]) } if (args.objective !== undefined || args.max_goal_rounds !== undefined) { @@ -197,6 +221,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'pause' ? ctx.goals.pause(execution.agent, ref) : ctx.goals.resume(execution.agent, ref) + observeMutation(terminalTurns, execution, goal) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) } const authority = completionAuthority(ctx, execution) @@ -211,6 +236,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'complete' ? ctx.goals.complete(execution.agent, ref) : ctx.goals.block(execution.agent, ref) + observeMutation(terminalTurns, execution, goal) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present( diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index c5f1a00593..2532b16a15 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -287,6 +287,19 @@ describe('goal tool state transitions', () => { goal_id: goal['id'], revision: goal['revision'], action: 'resume', }, root.agent)) expect(goal).toMatchObject({ phase: 'active', revision: 4 }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined() + }) + + it('stops the current turn after a successful stopped-state mutation', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'pause cleanly' }) + const paused = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'pause', + }, root.agent) + expect(resultGoal(paused)).toMatchObject({ phase: 'paused' }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toEqual({ action: 'stop' }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined() }) it('rearms a restored active goal only after a new direct human prompt', async () => { From 689e926c0ad55bcf94704fdc9749114d3401a6f6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:30:15 +0800 Subject: [PATCH 03/11] docs(goal): structure tool model experience --- packages/goal/tool-goal/README.md | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index a3987e32e1..b2332307f8 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -33,21 +33,37 @@ The value must be a positive safe integer. It supplies both the hard lower bound ### System prompt -**What the model sees**: A fixed goal policy says when semantic human intent warrants creation, requires exact read-before-update refs, explains rearming after resume/fork, and limits completion/blocking claims. The configured threshold is interpolated into that guidance. +#### What the model sees -**Token effect**: Small fixed input cost on every request where this plugin's prompt registration is in scope. +A fixed goal policy says when semantic human intent warrants creation, requires exact read-before-update refs, explains rearming after resume/fork, and limits completion/blocking claims. The configured threshold is interpolated into that guidance. -#### Goal policy +##### Goal policy ```markdown Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. ``` +#### Token effect + +Small fixed input cost on every request where this plugin's prompt registration is in scope. + +#### KV Cache effect + +Prefix-stable while the plugin scope, configured threshold, and guidance text are unchanged. Activation, disposal, or configuration changes may invalidate reuse from this prompt section. + ### Tool schemas and results -**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 `` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. +#### What the model sees -**Token effect**: Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. +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 `` snapshot after the tool batch. `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. + +#### 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. ## Known Limitations and Deferred Work From 1a14b605695a35472b12afe2b1ed8724f9e9e00a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:38:14 +0800 Subject: [PATCH 04/11] test(goal): close terminal pause e2e --- .../tests/fixtures/goal/tool-goal/scripted-llm.ts | 2 +- packages/goal/tool-goal/tests/tool-goal.e2e.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts index 39498f8e39..7b635ba0bb 100644 --- a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts +++ b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts @@ -75,7 +75,7 @@ class GoalScriptAdapter extends LlmAdapter { if (goal === undefined) throw new Error('scripted goal state missing') return toolCall('update_goal', { goal_id: goal.id, revision: goal.revision, action: 'pause' }) } - if (prompt.text === 'pause') return textReply('GOAL PAUSED') + if (prompt.text === 'pause') return textReply('UNEXPECTED CONTINUATION AFTER PAUSE') return textReply('UNEXPECTED PROMPT') } } diff --git a/packages/goal/tool-goal/tests/tool-goal.e2e.ts b/packages/goal/tool-goal/tests/tool-goal.e2e.ts index 037b91d18d..34f7bf243c 100644 --- a/packages/goal/tool-goal/tests/tool-goal.e2e.ts +++ b/packages/goal/tool-goal/tests/tool-goal.e2e.ts @@ -16,6 +16,7 @@ const configPath = fileURLToPath(new URL( const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) const PROCESS_TIMEOUT_MS = 30_000 const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 +const PAUSED_RESULT = '"phase":"paused"' let child: ChildProcessWithoutNullStreams | undefined let workdir: string | undefined @@ -68,7 +69,8 @@ async function runComposition(): Promise<{ stdout: string; stderr: string }> { pauseSent = true proc.stdin.write('pause\n') } - if (!inputClosed && stdout.includes('GOAL PAUSED')) { + const pausedAt = stdout.indexOf(PAUSED_RESULT) + if (!inputClosed && pausedAt >= 0 && stdout.indexOf('\n> ', pausedAt) >= 0) { inputClosed = true proc.stdin.end() } @@ -98,7 +100,8 @@ describe('goal tools through a real Loader, app, and stdio process', () => { expect(stderr).not.toContain('UNHANDLED') expect(stdout).toContain('goal-tools e2e ready.') expect(stdout).toContain('GOAL CREATED') - expect(stdout).toContain('GOAL PAUSED') + expect(stdout).toContain(PAUSED_RESULT) + expect(stdout).not.toContain('UNEXPECTED CONTINUATION AFTER PAUSE') const logs = await jsonlFiles(join(workdir as string, '.sessions')) expect(logs).toHaveLength(1) From 89f9900be1ad5d5b02c376c9dd61eaf9bc143e01 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:47:05 +0800 Subject: [PATCH 05/11] fix(goal): preserve interactive human turns --- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +- .../2026-07-19-model-facing-goal-tools.md | 7 +- .../2026-07-19-model-facing-goal-tools.zh.md | 7 +- docs/cordis-catalog/events.md | 30 ++++----- docs/event-producer-consumer.md | 30 ++++----- docs/tool-catalog.md | 6 +- .../fixtures/goal/tool-goal/scripted-llm.ts | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 5 +- packages/goal/tool-goal/README.md | 5 +- packages/goal/tool-goal/src/authority.ts | 6 +- packages/goal/tool-goal/src/index.ts | 47 +++++++------ .../goal/tool-goal/tests/tool-goal.e2e.ts | 2 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 67 +++++++++++++++++-- website/zh-CN/api/harness/events.md | 30 ++++----- 15 files changed, 163 insertions(+), 89 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index af951c46ff..f857c5360e 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: 3d9a33566d3ae27f803cbe56b7464bd1da17c1eb -2026-07-19-model-facing-goal-tools.zh.md: 45ca2dfdc3523a3598add845224cda6f58d2c738 +2026-07-19-model-facing-goal-tools.md: e37eaabecfd1984e1198c26a460e78a92375dac1 +2026-07-19-model-facing-goal-tools.zh.md: c0d289271d2a8053193299c16a2bc9477f2f1038 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index 3d9a33566d..e37eaabecf 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -22,13 +22,13 @@ The prompt tells the model that it may infer goal intent from a direct human req All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state. -A successful update that leaves the goal stopped contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request after pause, block, or completion. A later successful resume in the same turn removes that contribution. +An autonomous goal round that successfully reports completion or blocking contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request. Direct-human mutations do not contribute a terminal stop: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary continuation folding. ### Execution authority Every call requires an `exec.agent` that is the exact running object in `AgentRegistry`, is the current inherited driver initiator, and has an open turn. These are execution-time checks and cannot be bypassed by prompt injection or hand-authored tool arguments. -Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model. +Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. User source is a host attestation: `Agent.send()` and `steer()` default an omitted source to `{ kind: 'user' }`, so non-human producers must label their own content. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model. Complete and blocked accept either direct-human authority or the exact current goal round. Goal-round authority requires a goal-sourced `user/message` whose goal id, revision, and round all equal the folded current goal. It grants only the two terminal reports. Direct human authority may stop a goal immediately. @@ -38,7 +38,7 @@ Complete and blocked accept either direct-human authority or the exact current g ## Testing -Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact live-agent and driver checks, root-versus-child authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, rearming after a session-start edge, compare-and-set and argument failures, exact goal-round completion, the configured blocking threshold, and immediate human blocking. A keyless Loader/stdio process test mounts the real goal, tool, loop, and persistence plugins through `cordis.yml`, drives scripted model tool calls, and reads the JSONL externally to verify the model-visible create/pause snapshots, structured tool results, and configured prompt text. +Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless Loader/stdio process test mounts the real goal, tool, loop, and persistence plugins through `cordis.yml`, drives scripted model tool calls through a human pause and assistant acknowledgment, and reads the JSONL externally to verify the model-visible create/pause snapshots, structured tool results, and configured prompt text. ## Alternatives considered @@ -61,5 +61,6 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr - Semantic classification of a substantial goal, a request to continue, objective completion, and the same blocking condition remains model judgment. An independent evaluator or completion certificate is deferred. - These tools mutate goal state but do not schedule goal rounds, classify abnormal driver stops, or cancel an active turn; the same-session driver owns those behaviors. +- Goal-round authority is dormant unless a separately mounted continuation driver admits goal-sourced user turns; this tool package never manufactures that authority itself. - Human slash-command discovery and rendering are deferred to the command-surface layer. - A scope can hide tool registrations while leaving the independently registered prompt section visible unless the deployment scopes both together. diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index 45ca2dfdc3..c0d289271d 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -22,13 +22,13 @@ Status: implemented 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。 -成功更新后若目标处于停止状态,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免在暂停、阻塞或完成后再发起一次不必要的模型请求。同一轮次中后续成功的恢复会移除该贡献。 +自主目标回合成功报告完成或阻塞后,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免再发起一次不必要的模型请求。直接人类发起的变更不会贡献终止决策:智能体可以确认该变更,并且并发的人类 steering(转向)仍可参与普通的继续执行折叠。 ### 执行权限 每次调用都要求存在 `exec.agent`,且它必须是 `AgentRegistry` 中完全相同的运行中对象、当前继承的驱动发起者,并处于开放轮次内。这些检查在执行时进行,不能通过提示词注入或手写工具参数绕过。 -创建、编辑、暂停与恢复还要求运行时根智能体的当前轮次已经接纳一条用户消息或用户 steering(转向)事件。根所有权来自实时智能体图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子智能体则仍然是子智能体,不能改变这些状态。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 +创建、编辑、暂停与恢复还要求运行时根智能体的当前轮次已经接纳一条用户消息或用户 steering(转向)事件。根所有权来自实时智能体图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子智能体则仍然是子智能体,不能改变这些状态。用户来源是宿主的证明:`Agent.send()` 和 `steer()` 会把省略的来源默认为 `{ kind: 'user' }`,因此非人类生产者必须标注自己的内容。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 完成与阻塞既接受直接人类权限,也接受准确的当前目标回合。目标回合权限要求存在一条来源为目标的 `user/message`,其中目标 id、修订号和回合都与折叠后的当前目标相等。它只授予这两种终止报告权限。直接人类权限可以立即停止目标。 @@ -38,7 +38,7 @@ Status: implemented ## 测试 -单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确实时智能体与驱动检查、根与子智能体权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、会话启动边沿后的重新激活、比较并交换与参数失败、准确目标回合的完成、可配置阻塞阈值,以及人类立即阻塞。无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实的目标、工具、循环和持久化插件,驱动脚本化模型工具调用,并从外部读取 JSONL,以验证模型可见的创建/暂停快照、结构化工具结果和配置后的提示词文本。 +单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实的目标、工具、循环和持久化插件,驱动脚本化模型工具调用经过人类暂停与智能体确认,并从外部读取 JSONL,以验证模型可见的创建/暂停快照、结构化工具结果和配置后的提示词文本。 ## 考虑过的替代方案 @@ -61,5 +61,6 @@ Status: implemented - 是否属于重大目标、是否要求继续、目标是否完成以及阻塞条件是否相同,仍由模型进行语义分类。独立评估器或完成证书予以延期。 - 这些工具会改变目标状态,但不调度目标回合、不分类异常驱动停止,也不取消活跃轮次;这些行为由同会话驱动器负责。 +- 除非另行挂载的继续执行驱动器接纳了目标来源的用户轮次,否则目标回合权限路径处于休眠状态;本工具包本身不会制造这种权限。 - 面向人类的斜杠命令发现与渲染延期到命令表面层。 - 若部署没有同时设定两个注册项的作用域,某个作用域可能隐藏工具注册,却保留独立注册的提示词段。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index c90eacde5e..cc27559e84 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:147`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:159`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:314`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -98,7 +98,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:267`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -121,7 +121,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -142,7 +142,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -163,7 +163,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -186,7 +186,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:229`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -211,7 +211,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -237,7 +237,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:241`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:244`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -259,7 +259,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:191`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -279,7 +279,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:165`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -301,7 +301,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -322,7 +322,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:291`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -343,7 +343,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d48349cdf0..b7a7f6bb12 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,21 +8,21 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:147`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:156`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:175`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:150`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:159`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:314`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:267`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:178`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:229`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:244`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:168`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:291`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:301`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 2bd4f5d3de..3b63dceb1c 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -410,7 +410,7 @@ Create one persisted same-session completion goal when the current direct human }, "max_goal_rounds": { "type": "number", - "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default." + "description": "Optional positive safe-integer limit on automatic continuation rounds." } }, "required": [ @@ -423,7 +423,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `get_goal` -Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. +Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. ```json { @@ -436,7 +436,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `update_goal` -Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. +Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. ```json { diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts index 7b635ba0bb..310737050d 100644 --- a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts +++ b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts @@ -1,4 +1,4 @@ -/** Deterministic adapter that creates, reads, then pauses one goal. */ +/** Deterministic adapter that creates, reads, pauses, then acknowledges one goal. */ import type { Context } from 'cordis' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' @@ -75,7 +75,7 @@ class GoalScriptAdapter extends LlmAdapter { if (goal === undefined) throw new Error('scripted goal state missing') return toolCall('update_goal', { goal_id: goal.id, revision: goal.revision, action: 'pause' }) } - if (prompt.text === 'pause') return textReply('UNEXPECTED CONTINUATION AFTER PAUSE') + if (prompt.text === 'pause') return textReply('GOAL PAUSED') return textReply('UNEXPECTED PROMPT') } } diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index c2b46f210d..6d2df3d957 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -54,7 +54,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). +- `agent.send(content, options?)` — queue a message; starts a turn when idle. Omitting `options.source` attests direct human input as `{ kind: 'user' }` and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 702861f407..e9ee359da8 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -25,7 +25,10 @@ export interface AgentOptions { model?: string } -/** Message options; an omitted source resolves to `{ kind: 'user' }`, so plugins must label their own content. */ +/** + * Message options. An omitted source attests direct human input as `{ kind: 'user' }` + * and may authorize policy consumers, so non-human producers must label their content. + */ export interface SendOptions { source?: MessageSource } diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 42f62b7ce9..1ac03ac04a 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -10,12 +10,14 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. -A successful mutation that leaves the goal stopped contributes the existing terminal `agent/turn-stop` decision for that physical turn. A later same-turn resume clears the contribution. This avoids an extra model request after pause, block, or completion without changing ordinary loop continuation. +An autonomous goal round that successfully reports `complete` or `blocked` contributes the existing terminal `agent/turn-stop` decision for that physical turn. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop. ## Authority Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does. +`{ kind: 'user' }` is a host attestation. `Agent.send()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. + Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted. Direct human authority may stop a goal immediately. ## Config @@ -70,4 +72,5 @@ Schemas are prefix-stable while their definitions and visibility are unchanged. - **Semantic intent remains model judgment** — execution can prove direct human provenance, not whether a request is substantial enough to merit a goal. - **Same-condition blocking remains model judgment** — the runtime enforces distinct admitted-round count, not semantic equivalence of obstacles; an independent evaluator is deferred. - **No scheduling or UI commands** — these tools mutate state only; the same-session driver and human command surfaces are separate stack layers. +- **Goal-round authority requires a driver** — the autonomous `complete`/`blocked` path is dormant unless a continuation driver admits goal-sourced user turns; mounting this tool package alone does not create them. - **Prompt registration is independent of filtering** — a scope may hide the tools while retaining their guidance unless the deployment scopes both registrations together. diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index a2238aab9c..41fe713dc6 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -62,7 +62,11 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE return { agent, ...openTurn(agent) } } -/** Whether an accepted human message appears in the current root-agent turn. */ +/** + * Whether host-attested human input appears in the current root-agent turn. + * An omitted `Agent.send()` / `steer()` source resolves to `user`, so non-human + * producers must supply their own source rather than inheriting this authority. + */ function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean { if (!ctx.agents.roots().includes(execution.agent)) return false return execution.events.some(event => diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 29068a4afa..5c395898b7 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -50,8 +50,8 @@ const CREATE_DESCRIPTION = + 'trivial single-turn work. Execution rejects non-human and subagent authority.' const GET_DESCRIPTION = - 'Read the current same-session goal, including its exact id/revision, durable phase, admitted ' - + 'round count, cap, and live process-local activation. Call this before updating a goal.' + 'Read the current same-session goal, including its exact id/revision, objective, phase, completed ' + + 'continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.' /** Render policy guidance with its deployment-selected blocked threshold. */ function guidance(blockedAfter: number): string { @@ -107,13 +107,14 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } } } -/** Remember whether one successful mutation makes this turn terminal. */ +/** Remember whether one autonomous terminal report should stop this turn. */ function observeMutation( terminalTurns: WeakMap, execution: GoalToolExecution, goal: GoalView, + autonomousTerminal: boolean, ): void { - if (goal.phase === 'active' && goal.activation === 'armed') { + if (!autonomousTerminal || (goal.phase === 'active' && goal.activation === 'armed')) { terminalTurns.delete(execution.agent) return } @@ -123,6 +124,8 @@ function observeMutation( /** Register the three Codex-shaped goal tools and their shared policy section. */ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) + // A stale entry cannot match a later loop turn because turn numbers increase + // monotonically within the agent's fixed session. const terminalTurns = new WeakMap() ctx.on('agent/turn-stop', (agent, turn) => { if (terminalTurns.get(agent) !== turn) return undefined @@ -160,7 +163,7 @@ export function apply(ctx: Context, config: Config): void { }, max_goal_rounds: { type: 'number', - description: 'Optional positive safe-integer cap; omission uses the goal-domain deployment default.', + description: 'Optional positive safe-integer limit on automatic continuation rounds.', }, }, execute(args, exec) { @@ -170,7 +173,7 @@ export function apply(ctx: Context, config: Config): void { objective: args.objective, ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, }) - observeMutation(terminalTurns, execution, goal) + observeMutation(terminalTurns, execution, goal, false) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present('Create goal', 'other', args.objective), @@ -179,8 +182,8 @@ export function apply(ctx: Context, config: Config): void { ctx.tools.register(defineTool({ name: 'update_goal', description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' - + 'top-level human turn. complete and blocked additionally accept the exact admitted goal ' - + 'round. blocked is rejected before the configured minimum round count; the model remains ' + + 'top-level human request. During an automatic continuation of the current goal, complete ' + + 'and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains ' + 'responsible for judging that the same condition persisted across those rounds.', parameters: { goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' }, @@ -204,27 +207,33 @@ export function apply(ctx: Context, config: Config): void { if (args.action === 'edit') { requireDirectHuman(ctx, execution) const goal = ctx.goals.edit(execution.agent, ref, replacements) - observeMutation(terminalTurns, execution, goal) + observeMutation(terminalTurns, execution, goal, false) return Promise.resolve([{ type: 'text', text: renderGoal(goal), }]) } + if (args.action === 'pause' || args.action === 'resume') { + requireDirectHuman(ctx, execution) + if (args.objective !== undefined || args.max_goal_rounds !== undefined) { + throw new HarnessError( + 'objective and max_goal_rounds are valid only with action edit', + 'GOAL_TOOL_INVALID_UPDATE', + ) + } + const goal = args.action === 'pause' + ? ctx.goals.pause(execution.agent, ref) + : ctx.goals.resume(execution.agent, ref) + observeMutation(terminalTurns, execution, goal, false) + return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + } + const authority = completionAuthority(ctx, execution) if (args.objective !== undefined || args.max_goal_rounds !== undefined) { throw new HarnessError( 'objective and max_goal_rounds are valid only with action edit', 'GOAL_TOOL_INVALID_UPDATE', ) } - if (args.action === 'pause' || args.action === 'resume') { - requireDirectHuman(ctx, execution) - const goal = args.action === 'pause' - ? ctx.goals.pause(execution.agent, ref) - : ctx.goals.resume(execution.agent, ref) - observeMutation(terminalTurns, execution, goal) - return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) - } - const authority = completionAuthority(ctx, execution) if (args.action === 'blocked' && authority.kind === 'goal-round' && authority.goal.roundsStarted < resolved.blockedAfterConsecutiveRounds) { throw new HarnessError( @@ -236,7 +245,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'complete' ? ctx.goals.complete(execution.agent, ref) : ctx.goals.block(execution.agent, ref) - observeMutation(terminalTurns, execution, goal) + observeMutation(terminalTurns, execution, goal, authority.kind === 'goal-round') return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present( diff --git a/packages/goal/tool-goal/tests/tool-goal.e2e.ts b/packages/goal/tool-goal/tests/tool-goal.e2e.ts index 34f7bf243c..b19a4ee851 100644 --- a/packages/goal/tool-goal/tests/tool-goal.e2e.ts +++ b/packages/goal/tool-goal/tests/tool-goal.e2e.ts @@ -101,7 +101,7 @@ describe('goal tools through a real Loader, app, and stdio process', () => { expect(stdout).toContain('goal-tools e2e ready.') expect(stdout).toContain('GOAL CREATED') expect(stdout).toContain(PAUSED_RESULT) - expect(stdout).not.toContain('UNEXPECTED CONTINUATION AFTER PAUSE') + expect(stdout).toContain('GOAL PAUSED') const logs = await jsonlFiles(join(workdir as string, '.sessions')) expect(logs).toHaveLength(1) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 2532b16a15..3950bf1e63 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -7,7 +7,7 @@ import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' @@ -20,8 +20,8 @@ interface StubAgent { } /** Build one registry-compatible live agent whose injections append in place. */ -function stubAgent(rawId: string): StubAgent { - const session = new Session(SessionId(rawId)) +function stubAgent(rawId: string, supplied?: Session): StubAgent { + const session = supplied ?? new Session(SessionId(rawId)) let status: AgentStatus = 'running' const agent: Agent = { id: session.id, @@ -219,6 +219,42 @@ describe('goal tool execution authority', () => { expect(childResult.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') }) + it('rejects stale agent objects and agents outside running status through the executor', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + const stale = { ...root.agent } + const staleResult = await execute(ctx, 'get_goal', {}, stale, stale) + expect(staleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + + root.setStatus('idle') + const idleResult = await execute(ctx, 'get_goal', {}, root.agent) + expect(idleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + }) + + it('treats a fork resumed as a runtime root as direct-human authority', async () => { + const { ctx, root } = await harness() + const originalTurn = openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'resume the fork' }) + closeTurn(root, originalTurn) + const forkId = SessionId('goal-tool-resumed-fork') + const forkSession = new Session(forkId, root.session.events, { + version: SESSION_FORMAT_VERSION, + id: forkId, + createdAt: Date.now(), + parentSession: root.session.id, + seedLength: root.session.seq, + }) + const fork = stubAgent(forkId, forkSession) + ctx.agents.register(fork.agent) + expect(ctx.goals.get(fork.agent)).toMatchObject({ id: created.id, activation: 'disarmed' }) + + openTurn(fork, { kind: 'user' }, '继续这个目标') + const resumed = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'resume', + }, fork.agent) + expect(resultGoal(resumed)).toMatchObject({ id: created.id, revision: 2, phase: 'active' }) + }) + it('rejects calls before a turn and after its end boundary', async () => { const { ctx, root } = await harness() const before = await execute(ctx, 'get_goal', {}, root.agent) @@ -237,6 +273,10 @@ describe('goal tool execution authority', () => { goal_id: 'goal-missing', revision: 1, action: 'complete', }, root.agent) expect(result.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + const malformed = await execute(ctx, 'update_goal', { + goal_id: 'goal-missing', revision: 1, action: 'pause', objective: 'probe', + }, root.agent) + expect(malformed.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') }) it('accepts direct human steering in a goal-sourced root turn', async () => { @@ -290,16 +330,29 @@ describe('goal tool state transitions', () => { expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined() }) - it('stops the current turn after a successful stopped-state mutation', async () => { + it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => { const { ctx, root } = await harness() - openTurn(root, { kind: 'user' }) + const humanTurn = openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'pause cleanly' }) const paused = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'pause', }, root.agent) expect(resultGoal(paused)).toMatchObject({ phase: 'paused' }) - expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toEqual({ action: 'stop' }) - expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined() + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', humanTurn)).toBeUndefined() + const resumed = resultGoal(await execute(ctx, 'update_goal', { + goal_id: created.id, revision: 2, action: 'resume', + }, root.agent)) + closeTurn(root, humanTurn) + + const roundTurn = openTurn(root, { + kind: 'goal', goalId: created.id, revision: resumed['revision'] as number, round: 1, + }) + const complete = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: resumed['revision'], action: 'complete', + }, root.agent) + expect(resultGoal(complete)).toMatchObject({ phase: 'complete' }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn)).toEqual({ action: 'stop' }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn)).toBeUndefined() }) it('rearms a restored active goal only after a new direct human prompt', async () => { diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index ebe71dc559..a880973ba5 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -28,7 +28,7 @@ A fully configured agent and live session were published. Setup is composition-o - `agent` — the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L147) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L150) ### agent/disposed @@ -50,7 +50,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef - `agent` — the exact agent removed from the registry. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L156) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L159) ### agent/error @@ -77,7 +77,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w - `step` — the step at which the failure surfaced. - `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L311) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L314) ### agent/post-step @@ -105,7 +105,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in - `step` — the open step number. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L264) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L267) ### agent/pre-step @@ -133,7 +133,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending - `step` — the pending step number. - `signal` — the turn abort signal. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L204) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L207) ### agent/prompt-submit @@ -158,7 +158,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca - `content` — the drained message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L214) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L217) ### agent/queued @@ -183,7 +183,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already - `content` — the accepted content blocks retained by the inbox. - `info` — the accepted source plus whether it entered as steering. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L175) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L178) ### agent/request @@ -211,7 +211,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha - `step` — the step whose request this is. - `config` — the config the loop would use (frozen); return a replacement to switch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L226) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L229) ### agent/request-error @@ -243,7 +243,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens - `retryAttempt` — zero-based number of prior recovery retries. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L278) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L281) ### agent/session-prefix @@ -273,7 +273,7 @@ Compose request-only messages placed before derived history. The frozen result i - `prefix` — the frozen seed; return an extended replacement. - `signal` — aborts composition when the step is torn down. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L241) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L244) ### agent/session-start @@ -298,7 +298,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to - `agent` — the agent whose session lifecycle began. - `source` — why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L188) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L191) ### agent/status @@ -321,7 +321,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no - `agent` — the agent whose status flipped. - `status` — the status just entered (the transition's destination). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L165) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L168) ### agent/step-result @@ -348,7 +348,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va - `step` — the step that produced the message. - `message` — the assistant message as assembled from the stream. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L252) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L255) ### agent/turn-continuation @@ -373,7 +373,7 @@ Override whether the turn continues. The default continues after tool calls or s - `turn` — the turn being continued or stopped. - `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L288) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L291) ### agent/turn-stop @@ -397,7 +397,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a - `agent` — the agent whose composed continuation outcome may be stopped. - `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L298) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L301) ## agent-loop/* From 6f4f459c73b336d0b82ba88d7e3b895e345f697b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:30:51 +0800 Subject: [PATCH 06/11] test(goal): cover terminal update validation --- packages/goal/tool-goal/tests/tool-goal.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 3950bf1e63..044fe2369a 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -384,6 +384,13 @@ describe('goal tool state transitions', () => { objective: 'not valid for pause', }, root.agent) expect(replacement.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const terminalUpdate = await execute(ctx, 'update_goal', { + goal_id: created.id, + revision: created.revision, + action: 'complete', + max_goal_rounds: 2, + }, root.agent) + expect(terminalUpdate.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const malformedRef = await execute(ctx, 'update_goal', { goal_id: '', revision: 0, action: 'edit', objective: 'x', }, root.agent) From df1643d5e53142a874191602758b9ecbed059624 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:25:16 +0800 Subject: [PATCH 07/11] fix(goal): remove unreachable terminal branch --- packages/goal/tool-goal/src/index.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 5c395898b7..cd105b3705 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -111,10 +111,9 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen function observeMutation( terminalTurns: WeakMap, execution: GoalToolExecution, - goal: GoalView, autonomousTerminal: boolean, ): void { - if (!autonomousTerminal || (goal.phase === 'active' && goal.activation === 'armed')) { + if (!autonomousTerminal) { terminalTurns.delete(execution.agent) return } @@ -173,7 +172,7 @@ export function apply(ctx: Context, config: Config): void { objective: args.objective, ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, }) - observeMutation(terminalTurns, execution, goal, false) + observeMutation(terminalTurns, execution, false) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present('Create goal', 'other', args.objective), @@ -207,7 +206,7 @@ export function apply(ctx: Context, config: Config): void { if (args.action === 'edit') { requireDirectHuman(ctx, execution) const goal = ctx.goals.edit(execution.agent, ref, replacements) - observeMutation(terminalTurns, execution, goal, false) + observeMutation(terminalTurns, execution, false) return Promise.resolve([{ type: 'text', text: renderGoal(goal), @@ -224,7 +223,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'pause' ? ctx.goals.pause(execution.agent, ref) : ctx.goals.resume(execution.agent, ref) - observeMutation(terminalTurns, execution, goal, false) + observeMutation(terminalTurns, execution, false) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) } const authority = completionAuthority(ctx, execution) @@ -245,7 +244,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'complete' ? ctx.goals.complete(execution.agent, ref) : ctx.goals.block(execution.agent, ref) - observeMutation(terminalTurns, execution, goal, authority.kind === 'goal-round') + observeMutation(terminalTurns, execution, authority.kind === 'goal-round') return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present( From 15640ca697b5bfcee514faad467a1584a6b0cf01 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:53:10 +0800 Subject: [PATCH 08/11] fix(goal): require explained model blockers --- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +- .../2026-07-19-model-facing-goal-tools.md | 8 +- .../2026-07-19-model-facing-goal-tools.zh.md | 8 +- docs/tool-catalog.md | 8 +- .../tests/fixtures/goal/tool-goal/cordis.yml | 26 ---- .../fixtures/goal/tool-goal/scripted-llm.ts | 88 ------------ .../headless-agent/goal.cordis.snapshot.yml | 13 ++ examples/headless-agent/goal.cordis.yml | 12 ++ .../headless-agent/tests/headless.snapshot.ts | 109 ++++++++++++--- .../tests/snapshots/goal-tools/input.json | 9 ++ .../snapshots/goal-tools/replay.override.json | 33 +++++ .../goal-tools/stream-json.expected.jsonl | 34 +++++ knip.json | 1 - packages/goal/tool-goal/README.md | 8 +- packages/goal/tool-goal/src/index.ts | 34 ++++- .../goal/tool-goal/tests/tool-goal.e2e.ts | 127 ------------------ .../goal/tool-goal/tests/tool-goal.spec.ts | 54 +++++++- 17 files changed, 289 insertions(+), 287 deletions(-) delete mode 100644 examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml delete mode 100644 examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts create mode 100644 examples/headless-agent/goal.cordis.snapshot.yml create mode 100644 examples/headless-agent/goal.cordis.yml create mode 100644 examples/headless-agent/tests/snapshots/goal-tools/input.json create mode 100644 examples/headless-agent/tests/snapshots/goal-tools/replay.override.json create mode 100644 examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl delete mode 100644 packages/goal/tool-goal/tests/tool-goal.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index f857c5360e..fd97351a0e 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: e37eaabecfd1984e1198c26a460e78a92375dac1 -2026-07-19-model-facing-goal-tools.zh.md: c0d289271d2a8053193299c16a2bc9477f2f1038 +2026-07-19-model-facing-goal-tools.md: 2ef77b53cd8b95c9cdffd12e20c723fb1cff3a5d +2026-07-19-model-facing-goal-tools.zh.md: 08619600355c71ccd30d608e3c4e5a7fba753d4e diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index e37eaabecf..2ef77b53cd 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -16,9 +16,9 @@ The surface also needs to preserve the separation between durable state and live ### Tools and model contract -`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`. +`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. -The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker. +The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker; a block report must name the concrete condition. All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state. @@ -34,11 +34,11 @@ Complete and blocked accept either direct-human authority or the exact current g ### Blocking threshold -`blockedAfterConsecutiveRounds` is a validated positive safe-integer configuration with default `3`. When an autonomous goal round calls `blocked`, the plugin mechanically requires at least that many admitted rounds; the configured value also appears in model guidance. The runtime cannot determine whether those rounds encountered the same blocking condition, so semantic equivalence remains a model judgment. This count is deliberately separate from the goal's generous continuation cap. +`blockedAfterConsecutiveRounds` is a validated positive safe-integer configuration with default `3`. When an autonomous goal round calls `blocked`, the plugin mechanically requires at least that many admitted rounds and a non-empty explanation; the configured value also appears in model guidance. The runtime cannot determine whether those rounds encountered the same blocking condition, so semantic equivalence remains a model judgment. This count is deliberately separate from the goal's generous continuation cap. ## Testing -Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless Loader/stdio process test mounts the real goal, tool, loop, and persistence plugins through `cordis.yml`, drives scripted model tool calls through a human pause and assistant acknowledgment, and reads the JSONL externally to verify the model-visible create/pause snapshots, structured tool results, and configured prompt text. +Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, conditional blocker explanations, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index c0d289271d..0861960035 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -16,9 +16,9 @@ Status: implemented ### 工具与模型契约 -`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效。 +`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。 -提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞。 +提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。 @@ -34,11 +34,11 @@ Status: implemented ### 阻塞阈值 -`blockedAfterConsecutiveRounds` 是经过校验的正安全整数配置,默认值为 `3`。自主目标回合调用 `blocked` 时,插件会机械地要求至少已经接纳该数量的回合;配置值也会出现在模型指导中。运行时无法判断这些回合是否遇到了语义上相同的阻塞条件,因此语义等价性仍由模型判断。该计数特意与目标的宽裕继续执行上限分离。 +`blockedAfterConsecutiveRounds` 是经过校验的正安全整数配置,默认值为 `3`。自主目标回合调用 `blocked` 时,插件会机械地要求至少已经接纳该数量的回合并提供非空说明;配置值也会出现在模型指导中。运行时无法判断这些回合是否遇到了语义上相同的阻塞条件,因此语义等价性仍由模型判断。该计数特意与目标的宽裕继续执行上限分离。 ## 测试 -单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实的目标、工具、循环和持久化插件,驱动脚本化模型工具调用经过人类暂停与智能体确认,并从外部读取 JSONL,以验证模型可见的创建/暂停快照、结构化工具结果和配置后的提示词文本。 +单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal` 和 `get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 ## 考虑过的替代方案 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index febcf6a4bb..0244e39629 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -423,7 +423,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `get_goal` -Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. +Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. ```json { @@ -436,7 +436,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `update_goal` -Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. +Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. ```json { @@ -468,6 +468,10 @@ Update the exact current goal revision. edit, pause, and resume require a direct "max_goal_rounds": { "type": "number", "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml b/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml deleted file mode 100644 index fa63160399..0000000000 --- a/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml +++ /dev/null @@ -1,26 +0,0 @@ -# Test-only composition: drive all three goal tools through a real root agent. -- id: scripted-llm - name: './scripted-llm.ts' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - -- id: goal - name: '@deepseek-ai/dsh-goal' - config: - defaultMaxGoalRounds: 11 - -- id: tool-goal - name: '@deepseek-ai/dsh-tool-goal' - config: - blockedAfterConsecutiveRounds: 3 - -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' - config: - provider: goal-script - model: goal-script - persona: 'Execute the deterministic goal-tool composition test.' - welcome: 'goal-tools e2e ready.' - persistenceRoot: './.sessions' - workspaceContext: false diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts deleted file mode 100644 index 310737050d..0000000000 --- a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** Deterministic adapter that creates, reads, pauses, then acknowledges one goal. */ - -import type { Context } from 'cordis' -import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm' - -interface GoalState { - readonly id: string - readonly revision: number -} - -/** Text from the latest ordinary user message, excluding raw goal-state context. */ -function latestPrompt(messages: readonly Message[]): { index: number; text: string } { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index] - if (message?.role !== 'user') continue - const text = message.content - .filter(block => block.type === 'text' && !block.text.startsWith('')) - .map(block => block.type === 'text' ? block.text : '') - .join('\n') - if (text.length > 0) return { index, text } - } - return { index: -1, text: '' } -} - -/** Parse the latest domain snapshot rendered into history. */ -function latestGoal(messages: readonly Message[]): GoalState | undefined { - for (const message of [...messages].reverse()) { - for (const block of [...message.content].reverse()) { - if (block.type !== 'text' || !block.text.startsWith('')) continue - const json = block.text.slice(''.length, -''.length) - const value = JSON.parse(json) as { goal?: GoalState } - if (value.goal !== undefined) return value.goal - } - } - return undefined -} - -/** Names of tool calls recorded after the latest ordinary prompt. */ -function callsAfter(messages: readonly Message[], index: number): string[] { - return messages.slice(index + 1).flatMap(message => message.content) - .filter(block => block.type === 'tool-call') - .map(block => block.type === 'tool-call' ? block.name : '') -} - -/** Emit one tool-call response. */ -async function* toolCall(name: string, args: object): AsyncIterable { - const id = CallId(`call-${name}`) - const raw = JSON.stringify(args) - yield { type: 'block-start', index: 0, blockType: 'tool-call' } - yield { type: 'tool-call-delta', index: 0, id, name, argumentsDelta: raw } - yield { type: 'block-end', index: 0, block: { type: 'tool-call', id, name, arguments: raw } } - yield { type: 'finish', reason: { kind: 'tool-calls' } } -} - -/** Emit one terminal text response. */ -async function* textReply(text: string): AsyncIterable { - yield { type: 'block-start', index: 0, blockType: 'text' } - yield { type: 'text-delta', index: 0, text } - yield { type: 'block-end', index: 0, block: { type: 'text', text } } - yield { type: 'finish', reason: { kind: 'stop' } } -} - -class GoalScriptAdapter extends LlmAdapter { - override stream(options: GenerateOptions): AsyncIterable { - const prompt = latestPrompt(options.messages) - const calls = callsAfter(options.messages, prompt.index) - if (prompt.text === 'start' && !calls.includes('create_goal')) { - return toolCall('create_goal', { objective: 'Finish the composed goal-tool proof', max_goal_rounds: 7 }) - } - if (prompt.text === 'start' && !calls.includes('get_goal')) return toolCall('get_goal', {}) - if (prompt.text === 'start') return textReply('GOAL CREATED') - if (prompt.text === 'pause' && !calls.includes('update_goal')) { - const goal = latestGoal(options.messages) - if (goal === undefined) throw new Error('scripted goal state missing') - return toolCall('update_goal', { goal_id: goal.id, revision: goal.revision, action: 'pause' }) - } - if (prompt.text === 'pause') return textReply('GOAL PAUSED') - return textReply('UNEXPECTED PROMPT') - } -} - -export const name = 'goal-tool-scripted-llm' -export const inject = ['llm'] - -export function apply(ctx: Context): void { - ctx.llm.registerAdapter(['goal-script'], new GoalScriptAdapter()) -} diff --git a/examples/headless-agent/goal.cordis.snapshot.yml b/examples/headless-agent/goal.cordis.snapshot.yml new file mode 100644 index 0000000000..185d3dd11e --- /dev/null +++ b/examples/headless-agent/goal.cordis.snapshot.yml @@ -0,0 +1,13 @@ +# Replay counterpart to goal.cordis.yml; only the live model is replaced. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./goal.cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + diff --git a/examples/headless-agent/goal.cordis.yml b/examples/headless-agent/goal.cordis.yml new file mode 100644 index 0000000000..01f1726100 --- /dev/null +++ b/examples/headless-agent/goal.cordis.yml @@ -0,0 +1,12 @@ +# Add the persisted goal domain and its model-facing tools to the real one-shot app. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: goal + name: '@deepseek-ai/dsh-goal' + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index dc448b9b23..d799c6250d 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -11,10 +11,12 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l import { describe, expect, it } from 'vitest' const snapshotsDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') -const scenarioDir = join(snapshotsDir, 'advanced-toolchain') -const sessionFixture = join(scenarioDir, 'session.jsonl') -const streamExpected = join(scenarioDir, 'stream-json.expected.jsonl') -const configPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url)) +const advancedScenarioDir = join(snapshotsDir, 'advanced-toolchain') +const advancedSessionFixture = join(advancedScenarioDir, 'session.jsonl') +const advancedStreamExpected = join(advancedScenarioDir, 'stream-json.expected.jsonl') +const advancedConfigPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url)) +const goalScenarioDir = join(snapshotsDir, 'goal-tools') +const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' @@ -70,12 +72,36 @@ function normalizeHeadlessStream(rawStdout: string, cwd: string): string { return normalizeStdout(`${normalizedRecords.map(record => JSON.stringify(record)).join('\n')}\n`, context) } -async function advancedPrompt(): Promise { - const input = JSON.parse(await readFile(join(scenarioDir, 'input.json'), 'utf8')) as { +/** Zero durable goal timestamps inside both metadata records and rendered XML JSON. */ +function normalizeGoalTimestamps(value: unknown): unknown { + if (typeof value === 'string') { + return value.replace(/("(?:createdAt|updatedAt|clearedAt)":)\d+/g, '$10') + } + if (Array.isArray(value)) return value.map(normalizeGoalTimestamps) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [ + key, + ['createdAt', 'updatedAt', 'clearedAt'].includes(key) && typeof item === 'number' + ? 0 + : normalizeGoalTimestamps(item), + ])) + } + return value +} + +/** Normalize the stream's durable goal timestamps after the shared scrubbers. */ +function normalizeGoalStream(rawStdout: string, cwd: string): string { + return parseJsonl(normalizeHeadlessStream(rawStdout, cwd)) + .map(record => JSON.stringify(normalizeGoalTimestamps(record))) + .join('\n') + '\n' +} + +async function scenarioPrompt(dir: string, label: string): Promise { + const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as { steps?: { op?: unknown; text?: unknown }[] } const prompt = input.steps?.find(step => step.op === 'prompt')?.text - if (typeof prompt !== 'string') throw new Error('advanced-toolchain input has no prompt step') + if (typeof prompt !== 'string') throw new Error(`${label} input has no prompt step`) return prompt } @@ -90,24 +116,27 @@ async function persistedLogs(cwd: string): Promise { describe('headless stream-json snapshots', () => { it('replays the advanced toolchain through the one-shot app', async () => { - const prompt = await advancedPrompt() + const prompt = await scenarioPrompt(advancedScenarioDir, 'advanced-toolchain') const expectedSessions = await Promise.all([ - sessionFixture, - join(scenarioDir, 'session.1.jsonl'), - join(scenarioDir, 'session.2.jsonl'), + advancedSessionFixture, + join(advancedScenarioDir, 'session.1.jsonl'), + join(advancedScenarioDir, 'session.2.jsonl'), ].map(file => readFile(file, 'utf8'))) let runCwd = '' const result = await runLoaderSmoke({ label: 'advanced headless stream-json snapshot', tempDirPrefix: 'headless-snapshot-advanced-', binScript, - configPath, - binArgs: ['--config', configPath, '--output-format', 'stream-json', prompt], + configPath: advancedConfigPath, + binArgs: ['--config', advancedConfigPath, '--output-format', 'stream-json', prompt], tsconfigPath, env: { DSH_SNAPSHOT: 'replay', - DSH_SNAPSHOT_FILE: sessionFixture, - DSH_SNAPSHOT_CHILD_FILES: [join(scenarioDir, 'session.1.jsonl'), join(scenarioDir, 'session.2.jsonl')].join(delimiter), + DSH_SNAPSHOT_FILE: advancedSessionFixture, + DSH_SNAPSHOT_CHILD_FILES: [ + join(advancedScenarioDir, 'session.1.jsonl'), + join(advancedScenarioDir, 'session.2.jsonl'), + ].join(delimiter), NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), }, prepare: (cwd) => { runCwd = cwd }, @@ -134,6 +163,56 @@ describe('headless stream-json snapshots', () => { expect(result.stderr).toBe('') const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(advancedStreamExpected, normalized) + expect(normalized).toBe(await readFile(advancedStreamExpected, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('replays persisted goal tools through the one-shot app', async () => { + const prompt = await scenarioPrompt(goalScenarioDir, 'goal-tools') + const streamExpected = join(goalScenarioDir, 'stream-json.expected.jsonl') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'goal tools headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-goal-tools-', + binScript, + configPath: goalConfigPath, + binArgs: ['--config', goalConfigPath, '--output-format', 'stream-json', prompt], + tsconfigPath, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: join(goalScenarioDir, 'session.jsonl'), + DSH_SNAPSHOT_OVERRIDE: join(goalScenarioDir, 'replay.override.json'), + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd) + expect(logs).toHaveLength(1) + const records = parseJsonl(logs[0]?.content ?? '') + const calls = records.filter(record => record.type === 'tool/call') + .map(record => (record.data as JsonObject | undefined)?.name) + expect(calls).toEqual(['create_goal', 'get_goal']) + const goalChanges = records.filter((record) => { + if (record.type !== 'context/message') return false + const data = record.data as JsonObject | undefined + const meta = data?.meta as JsonObject | undefined + return meta?.kind === 'goal/change' + }) + expect(goalChanges).toHaveLength(1) + const data = goalChanges[0]?.data as JsonObject | undefined + const meta = data?.meta as JsonObject | undefined + const goal = meta?.goal as JsonObject | undefined + expect(meta?.operation).toBe('create') + expect(goal).toMatchObject({ + objective: 'Finish the headless goal-tool snapshot proof', + phase: 'active', + maxGoalRounds: 7, + }) + }, + }) + + expect(result.stderr).toBe('') + const normalized = normalizeGoalStream(result.stdout, runCwd) if (refreshing) await writeFile(streamExpected, normalized) expect(normalized).toBe(await readFile(streamExpected, 'utf8')) }, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/examples/headless-agent/tests/snapshots/goal-tools/input.json b/examples/headless-agent/tests/snapshots/goal-tools/input.json new file mode 100644 index 0000000000..cb0b3bbd82 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/goal-tools/input.json @@ -0,0 +1,9 @@ +{ + "steps": [ + { + "op": "prompt", + "text": "Create a durable goal to finish the snapshot proof, then inspect it." + } + ] +} + diff --git a/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json b/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json new file mode 100644 index 0000000000..7e4fd90c6c --- /dev/null +++ b/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json @@ -0,0 +1,33 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_create", "name": "create_goal", "argumentsDelta": "{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_create", "name": "create_goal", "arguments": "{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}" } }, + { "type": "usage", "usage": { "inputTokens": 20, "outputTokens": 8 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_get", "name": "get_goal", "argumentsDelta": "{}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_get", "name": "get_goal", "arguments": "{}" } }, + { "type": "usage", "usage": { "inputTokens": 30, "outputTokens": 4 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "GOAL READY" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "GOAL READY" } }, + { "type": "usage", "usage": { "inputTokens": 35, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] + diff --git a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl new file mode 100644 index 0000000000..b6e9ad7e70 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl @@ -0,0 +1,34 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Create a durable goal to finish the snapshot proof, then inspect it."}],"source":{"kind":"user"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"context/message","seq":12,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"envelope":"raw","meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"text","text":"GOAL READY"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":31,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":32,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"GOAL READY","reason":{"kind":"completed"},"usage":{"inputTokens":85,"outputTokens":14}} diff --git a/knip.json b/knip.json index eb4eff3a9f..9b7b511c44 100644 --- a/knip.json +++ b/knip.json @@ -11,7 +11,6 @@ "entry": [ "echo-agent/src/*.ts", "echo-agent/tests/fixtures/goal/goal/seed-goal.ts", - "echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts", "headless-agent/tests/fixtures/cli-mock-llm.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 1ac03ac04a..3b97d7aa82 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -4,9 +4,9 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal ## Tools -- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, and current process-local activation. +- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation. - `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution. -- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`. +- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. @@ -18,7 +18,7 @@ Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` in `{ kind: 'user' }` is a host attestation. `Agent.send()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. -Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted. Direct human authority may stop a goal immediately. +Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted and must describe it in `blocked_reason`. Direct human authority may stop a goal immediately. ## Config @@ -42,7 +42,7 @@ A fixed goal policy says when semantic human intent warrants creation, requires ##### Goal policy ```markdown -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. ``` #### Token effect diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index cd105b3705..075264f93e 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -51,7 +51,8 @@ const CREATE_DESCRIPTION = const GET_DESCRIPTION = 'Read the current same-session goal, including its exact id/revision, objective, phase, completed ' - + 'continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.' + + 'continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. ' + + 'Call this before updating a goal.' /** Render policy guidance with its deployment-selected blocked threshold. */ function guidance(blockedAfter: number): string { @@ -62,7 +63,8 @@ function guidance(blockedAfter: number): string { + 'a human asks to continue or resume in any wording or language, use update_goal action ' + 'resume to rearm it. Mark complete only when the objective is actually achieved. Mark ' + `blocked only after the same blocking condition persists for at least ${blockedAfter} ` - + 'consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked.' + + 'consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, ' + + 'or useful remaining work is not blocked.' } /** Validate config even when apply is called directly outside Loader normalization. */ @@ -97,6 +99,7 @@ function renderGoal(goal: GoalView | undefined): string { phase: goal.phase, roundsStarted: goal.roundsStarted, maxGoalRounds: goal.maxGoalRounds, + ...goal.blockedReason === undefined ? {} : { blockedReason: goal.blockedReason }, }, activation: goal.activation, }) @@ -183,7 +186,7 @@ export function apply(ctx: Context, config: Config): void { description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' + 'top-level human request. During an automatic continuation of the current goal, complete ' + 'and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains ' - + 'responsible for judging that the same condition persisted across those rounds.', + + 'responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.', parameters: { goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' }, revision: { type: 'number', required: true, description: 'Exact positive revision returned by get_goal.' }, @@ -195,6 +198,10 @@ export function apply(ctx: Context, config: Config): void { }, objective: { type: 'string', description: 'Replacement objective; valid only with action edit.' }, max_goal_rounds: { type: 'number', description: 'Replacement cap; valid only with action edit.' }, + blocked_reason: { + type: 'string', + description: 'Concrete blocking condition; required only with action blocked.', + }, }, execute(args, exec) { const execution = goalToolExecution(ctx, exec) @@ -205,6 +212,9 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'edit') { requireDirectHuman(ctx, execution) + if (args.blocked_reason !== undefined) { + throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') + } const goal = ctx.goals.edit(execution.agent, ref, replacements) observeMutation(terminalTurns, execution, false) return Promise.resolve([{ @@ -214,9 +224,9 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'pause' || args.action === 'resume') { requireDirectHuman(ctx, execution) - if (args.objective !== undefined || args.max_goal_rounds !== undefined) { + if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) { throw new HarnessError( - 'objective and max_goal_rounds are valid only with action edit', + 'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE', ) } @@ -233,6 +243,13 @@ export function apply(ctx: Context, config: Config): void { 'GOAL_TOOL_INVALID_UPDATE', ) } + if (args.action === 'complete' && args.blocked_reason !== undefined) { + throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') + } + if (args.action === 'blocked' + && (args.blocked_reason === undefined || args.blocked_reason.trim().length === 0)) { + throw new HarnessError('blocked_reason is required with action blocked', 'GOAL_TOOL_INVALID_UPDATE') + } if (args.action === 'blocked' && authority.kind === 'goal-round' && authority.goal.roundsStarted < resolved.blockedAfterConsecutiveRounds) { throw new HarnessError( @@ -243,14 +260,17 @@ export function apply(ctx: Context, config: Config): void { } const goal = args.action === 'complete' ? ctx.goals.complete(execution.agent, ref) - : ctx.goals.block(execution.agent, ref) + : ctx.goals.block(execution.agent, ref, { + code: 'model-reported', + message: args.blocked_reason as string, + }) observeMutation(terminalTurns, execution, authority.kind === 'goal-round') return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present( `${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`, 'other', - args.objective ?? args.goal_id, + args.blocked_reason ?? args.objective ?? args.goal_id, ), })) } diff --git a/packages/goal/tool-goal/tests/tool-goal.e2e.ts b/packages/goal/tool-goal/tests/tool-goal.e2e.ts deleted file mode 100644 index b19a4ee851..0000000000 --- a/packages/goal/tool-goal/tests/tool-goal.e2e.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' -import { decodeGoalChange } from '@deepseek-ai/dsh-goal' -import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' - -const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL( - '../../../../examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml', - import.meta.url, -)) -const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) -const PROCESS_TIMEOUT_MS = 30_000 -const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 -const PAUSED_RESULT = '"phase":"paused"' - -let child: ChildProcessWithoutNullStreams | undefined -let workdir: string | undefined - -afterEach(async () => { - if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') - child = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - workdir = undefined -}) - -async function jsonlFiles(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }) - const paths = await Promise.all(entries.map(async (entry) => { - const path = join(dir, entry.name) - if (entry.isDirectory()) return jsonlFiles(path) - return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] - })) - return paths.flat() -} - -async function runComposition(): Promise<{ stdout: string; stderr: string }> { - workdir = await mkdtemp(join(tmpdir(), 'goal-tools-e2e-')) - const cwd = workdir - return new Promise((resolve, reject) => { - const launch = resolveExampleLaunch({ - srcBin: binScript, - configArgs: [configPath], - tsconfigPath: repoTsconfig, - exposeInternals: true, - env: { - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - }) - const proc = spawn(launch.command, launch.args, { - cwd, - env: { ...process.env, ...launch.env }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - child = proc - let stdout = '' - let stderr = '' - let pauseSent = false - let inputClosed = false - proc.stdout.setEncoding('utf8') - proc.stdout.on('data', (chunk: string) => { - stdout += chunk - if (!pauseSent && stdout.includes('GOAL CREATED') && stdout.includes('\n> ')) { - pauseSent = true - proc.stdin.write('pause\n') - } - const pausedAt = stdout.indexOf(PAUSED_RESULT) - if (!inputClosed && pausedAt >= 0 && stdout.indexOf('\n> ', pausedAt) >= 0) { - inputClosed = true - proc.stdin.end() - } - }) - proc.stderr.setEncoding('utf8') - proc.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const timer = setTimeout(() => { - proc.kill('SIGKILL') - reject(new Error( - `goal-tools e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`, - )) - }, PROCESS_TIMEOUT_MS) - proc.on('exit', (code) => { - clearTimeout(timer) - if (code === 0) resolve({ stdout, stderr }) - else reject(new Error(`goal-tools e2e exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }) - proc.on('error', (error) => { clearTimeout(timer); reject(error) }) - proc.stdin.write('start\n') - }) -} - -describe('goal tools through a real Loader, app, and stdio process', () => { - it('creates, reads, and pauses one root goal with durable tool and state records', async () => { - const { stdout, stderr } = await runComposition() - expect(stderr).not.toContain('UNHANDLED') - expect(stdout).toContain('goal-tools e2e ready.') - expect(stdout).toContain('GOAL CREATED') - expect(stdout).toContain(PAUSED_RESULT) - expect(stdout).toContain('GOAL PAUSED') - - const logs = await jsonlFiles(join(workdir as string, '.sessions')) - expect(logs).toHaveLength(1) - const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') - const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) - const calls = events.filter(event => event.type === 'tool/call') - expect(calls.map(event => event.data.name)).toEqual(['create_goal', 'get_goal', 'update_goal']) - const results = events.filter(event => event.type === 'tool/result') - expect(results).toHaveLength(3) - expect(results.every(event => !event.data.isError)).toBe(true) - - const changes = events - .filter(event => event.type === 'context/message' && event.data.source.kind === 'goal') - .map(event => event.type === 'context/message' ? decodeGoalChange(event.data.meta) : undefined) - expect(changes.map(change => change?.operation)).toEqual(['create', 'pause']) - expect(changes[1]).toMatchObject({ goal: { phase: 'paused', revision: 2, maxGoalRounds: 7 } }) - expect(JSON.stringify(changes)).not.toContain('activation') - - const headers = events.filter(event => event.type === 'request/header') - expect(JSON.stringify(headers)).toContain('infer goal intent') - expect(JSON.stringify(headers)).toContain('at least 3 consecutive goal rounds') - }, TEST_TIMEOUT_MS) -}) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 044fe2369a..cd45c145c7 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -135,8 +135,8 @@ describe('goal tool registration and presentation', () => { card: 'generic', title: 'Create goal', kind: 'other', rawInput: 'ship', }) expect(ctx.tools.get('update_goal')?.presentCall?.({ - goal_id: 'goal-1', revision: 2, action: 'blocked', - })).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'goal-1' }) + goal_id: 'goal-1', revision: 2, action: 'blocked', blocked_reason: 'Waiting for a human choice.', + })).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'Waiting for a human choice.' }) expect(ctx.tools.get('update_goal')?.presentCall?.({ goal_id: 'goal-1', revision: 2, action: 'resume', })).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' }) @@ -391,6 +391,26 @@ describe('goal tool state transitions', () => { max_goal_rounds: 2, }, root.agent) expect(terminalUpdate.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const blockedWithoutReason = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'blocked', + }, root.agent) + expect(blockedWithoutReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const blockedWithEmptyReason = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'blocked', blocked_reason: ' ', + }, root.agent) + expect(blockedWithEmptyReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const completeWithReason = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'complete', blocked_reason: 'Not a blocker.', + }, root.agent) + expect(completeWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const editWithReason = await execute(ctx, 'update_goal', { + goal_id: created.id, + revision: created.revision, + action: 'edit', + objective: 'still valid', + blocked_reason: 'Not valid for edit.', + }, root.agent) + expect(editWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const malformedRef = await execute(ctx, 'update_goal', { goal_id: '', revision: 0, action: 'edit', objective: 'x', }, root.agent) @@ -423,16 +443,26 @@ describe('goal tool state transitions', () => { for (let round = 1; round <= 2; round += 1) { turn = openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round }) const result = await execute(ctx, 'update_goal', { - goal_id: ref.id, revision: ref.revision, action: 'blocked', + goal_id: ref.id, + revision: ref.revision, + action: 'blocked', + blocked_reason: 'The required credential is still unavailable.', }, root.agent) expect(result.error?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD') closeTurn(root, turn) } openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 3 }) const blocked = await execute(ctx, 'update_goal', { - goal_id: ref.id, revision: ref.revision, action: 'blocked', + goal_id: ref.id, + revision: ref.revision, + action: 'blocked', + blocked_reason: 'The required credential is still unavailable.', }, root.agent) - expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked', roundsStarted: 3 }) + expect(resultGoal(blocked)).toMatchObject({ + phase: 'blocked', + blockedReason: { code: 'model-reported', message: 'The required credential is still unavailable.' }, + roundsStarted: 3, + }) }) it('lets direct human authority block before the model threshold', async () => { @@ -440,8 +470,18 @@ describe('goal tool state transitions', () => { openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'human stop' }) const blocked = await execute(ctx, 'update_goal', { - goal_id: created.id, revision: created.revision, action: 'blocked', + goal_id: created.id, + revision: created.revision, + action: 'blocked', + blocked_reason: 'The user asked to stop until a prerequisite is available.', }, root.agent) - expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked', roundsStarted: 0 }) + expect(resultGoal(blocked)).toMatchObject({ + phase: 'blocked', + blockedReason: { + code: 'model-reported', + message: 'The user asked to stop until a prerequisite is available.', + }, + roundsStarted: 0, + }) }) }) From 23330b7a7d00fa6f4f058423aea57d4552792808 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:35:01 +0800 Subject: [PATCH 09/11] chore(goal): remove retired e2e dependencies --- knip.json | 2 +- packages/goal/tool-goal/package.json | 1 - pnpm-lock.yaml | 3 --- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/knip.json b/knip.json index 9b7b511c44..8fa18dc524 100644 --- a/knip.json +++ b/knip.json @@ -72,7 +72,7 @@ "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/goal/tool-goal": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/code-runtime/code-runtime-worker": { diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 28cd6839f8..b9b4912cf4 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -38,7 +38,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 957e3d05dd..9f7a641df7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,9 +425,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From 671ef4d7eec6fa253e0e6a15297d3dd23fe67507 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:49:29 +0800 Subject: [PATCH 10/11] fix(goal): synchronize workspace lock entries --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f7a641df7..8e04aef04c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,6 +425,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1034,9 +1037,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From 1dcfc63a789ae207a7bb61fd6ad860ab1da6fd6f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:26:26 +0800 Subject: [PATCH 11/11] docs(goal): refresh event source links --- docs/cordis-catalog/events.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index dfe0fdb502..92ccab90a8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:153`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:159`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:314`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:317`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -98,7 +98,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:267`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:270`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -121,7 +121,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:210`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -142,7 +142,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -163,7 +163,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:181`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -186,7 +186,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:229`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:232`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -211,7 +211,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:284`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -237,7 +237,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:244`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -259,7 +259,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:191`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:194`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -279,7 +279,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:171`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -301,7 +301,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:258`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -322,7 +322,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:291`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:294`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -343,7 +343,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) ## `agent-loop/*`