diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 4009fcab38..430e4e9f13 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.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-package-invariant-runtime-contracts.md: aa67747878654d8958e52357f96f3654ca64df39 -2026-07-19-package-invariant-runtime-contracts.zh.md: c0d308f95061a9f579201a72a4b011f98f79f891 +2026-07-19-package-invariant-runtime-contracts.md: 4063acedfae1586e6a85e3b14a45bafe894a7a1f +2026-07-19-package-invariant-runtime-contracts.zh.md: a74d47329116438c7adadb9c05bd0f3ca9549f56 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index aa67747878..4063acedfa 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -27,7 +27,7 @@ The central `dsh-invariants` service owns only configuration, registration uniqu ### Implemented checks -The current 95-package workspace has 20 executable companions and 75 justified empty companions. +The current 100-package workspace has 21 executable companions and 79 justified empty companions. | Owner | Runtime relationship | |---|---| @@ -44,6 +44,7 @@ The current 95-package workspace has 20 executable companions and 75 justified e | `dsh-sandbox-policy` | Durable `sandbox/mode` events use the closed sandbox-mode vocabulary. | | `dsh-fs` | Filesystem decision/observation events carry usable target and version identities. | | `dsh-goal` | Durable goal snapshots preserve source attribution, rendered content, revisions, lifecycle and timestamp relationships, and sequential admitted rounds. | +| `dsh-goal-session` | Goal-sourced continuation messages match the prompt reconstructed from the preceding durable goal state. | | `dsh-subagent` | Provider add/remove and child start/end events preserve identity and pairing. | | `dsh-permission` | Durable permission decisions name a preset in the active permission table. | | `dsh-user-approval` | Approval asked/decided records pair by call and use valid outcomes and policies. | diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index c0d308f950..a74d473291 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -27,7 +27,7 @@ Status: implemented ### 已实施的检查 -当前 95 个包的 workspace 包含 20 个可执行 companion 和 75 个有理由的空 companion。 +当前 100 个包的 workspace 包含 21 个可执行 companion 和 79 个有理由的空 companion。 | 所有者 | 运行时关系 | |---|---| @@ -44,6 +44,7 @@ Status: implemented | `dsh-sandbox-policy` | 持久化 `sandbox/mode` 事件必须使用封闭的 sandbox-mode 词表。 | | `dsh-fs` | 文件系统决策/观测事件必须携带可用的 target 和 version 身份。 | | `dsh-goal` | 持久化目标快照保持来源归属、渲染内容、修订号、生命周期和时间戳关系,并保证已准入的目标回合连续编号。 | +| `dsh-goal-session` | 目标来源的继续执行消息必须匹配根据此前持久化目标状态重建的提示词。 | | `dsh-subagent` | provider add/remove 和 child start/end 事件必须保持身份与配对。 | | `dsh-permission` | 持久化 permission 决策必须引用当前 permission 表中的 preset。 | | `dsh-user-approval` | approval asked/decided 记录按 call 配对,并使用有效 outcome 和 policy。 | diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index c37979ae66..a3e8c3ad8a 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-owned-invariant-service.md: bd167f8da1e855b703b75a60e2dbd8959f5bf9b3 -2026-07-19-package-owned-invariant-service.zh.md: 6bd4f30123b7c7ba39266d5d31dd4b378604fd5a +2026-07-19-package-owned-invariant-service.md: 2443a8f7d04b96f51bb798130078a7457f78b2a1 +2026-07-19-package-owned-invariant-service.zh.md: 3c71d3b7f99a507d4c0236b7ef6dc0794814cdc8 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md index bd167f8da1..2443a8f7d0 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -64,7 +64,7 @@ The former functional-plugin entrypoint and one-argument `InvariantError` constr | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | model-request reconstruction | -These four owners supplied the initial stateful checks. The follow-up runtime-contract decision adds checks for fourteen more owners with real event or mutable-data relationships and records justified empty companions for the rest. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. +These four owners supplied the initial stateful checks. The follow-up runtime-contract decision adds checks for seventeen more owners with real event or mutable-data relationships and records justified empty companions for the rest. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. `verify-package-invariants` discovers every workspace package and rejects missing companion source, generated markers, unexplained empty installers, non-empty installers that omit or ignore the reporter, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index 6bd4f30123..3c71d3b7f9 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -64,7 +64,7 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped event carrier 存在性与主体一致性 | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | 模型请求重建 | -这四个所有者提供了首批有状态检查。后续运行时契约决策为另外十四个确有事件或可变数据关系的所有者增加检查,并为其余包记录有理由的空 companion。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 +这四个所有者提供了首批有状态检查。后续运行时契约决策为另外十七个确有事件或可变数据关系的所有者增加检查,并为其余包记录有理由的空 companion。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 `verify-package-invariants` 会发现每个工作区包,并拒绝缺失的伴随插件源码、生成标记、没有解释的空 installer、缺少或不使用失败报告器的非空 installer、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。 diff --git a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml similarity index 65% rename from .agents/notes/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml rename to .agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml index 743d50cc5e..1a8c03cb52 100644 --- a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-16-harness-level-loop.md: 97041b1c08d5fb85a222824ee6d8dfd55e74f4bd -2026-07-16-harness-level-loop.zh.md: 6460254b07b0e185ff2a40c415d34bd25b6ad925 +2026-07-16-harness-level-loop.md: 9a9511b9dcea1b5fdc90f4fc716c4399f2346967 +2026-07-16-harness-level-loop.zh.md: 284e73051eaaa4633b9f56367de9096dadc8184e diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md new file mode 100644 index 0000000000..9a9511b9dc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md @@ -0,0 +1,129 @@ +# Agent Note: Harness-level goal-based execution + +Status: implemented + +English | [中文](2026-07-16-harness-level-loop.zh.md) + +## Problem + +The concrete agent loop owns one turn: it drains admitted input, performs one or more model-and-tool steps, and stops. Substantial objectives often need an outer policy that can begin another turn, retain progress, stop at a budget, and remain intelligible to humans. A timed prompt, a same-session continuation, and a fresh-agent Ralph attempt all repeat work, but they do not share the same state, authority, memory, or lifecycle. + +Treating every repeated action as one generic “loop” obscures those differences. Same-session work must persist the human objective in the existing transcript while preserving conversation context. Ralph work must intentionally discard conversation context and use the workspace plus a bounded handoff. Human-facing status must not imply that reopening a session silently authorizes more work. Completion and blocker claims also need an explicit trust boundary rather than being smuggled into a scheduler abstraction. + +The repository therefore needs goal-based execution above the turn/step loop, but it does not need a speculative universal loop service that combines persistence, evaluation, budgeting, scheduling, handoff, background tasks, and UI. + +## Decision + +This proposal is implemented in amended form as two explicit plugin policies over existing seams: + +1. **Same-session goals** retain one durable objective in the current session and admit goal-attributed continuation turns only while live activation is armed. +2. **Fresh-agent Ralph runs** execute a fixed foreground workflow whose rounds each spawn a new structured child with no conversation seed. + +There is no `packages/loop/` family, `LoopDriver`, `LoopId`, universal `StopCondition`, or model-facing generic `loop` tool. The two policies share the repository's ordinary agent, session, tools, workflow, subagent, and UI extension seams, but they do not pretend that one lifecycle fits both. + +### Vocabulary and policy boundary + +The same-session hierarchy is **Goal → Goal Round → Turn → Step**. A goal round is one continuation cycle admitted for the current goal and materialized as one goal-sourced turn. Human or unrelated turns in the same session do not consume the goal-round cap, and a turn may still contain multiple model/tool steps. + +The fresh-agent hierarchy is **Ralph Run → Ralph Round → fresh child Turn → Step**. One Ralph round creates one child session. The parent transcript and prior child transcripts are not seed context; the shared workspace and one bounded structured report carry cross-round state. + +“Round” is therefore an outer policy iteration, not a synonym for every session turn. The concrete `dsh-agent-loop` remains the turn/step engine. The same-session driver uses public agent and session events; its only core addition is the generic observe-before-cancel `agent/cancel-requested` notification needed by any lifecycle policy that must settle cancellation safely. + +Time-based `/loop` or scheduled execution is a third policy and is not implemented by this decision. It belongs with a scheduler rather than either goal family. + +### Package topology and owning verbs + +| Package | Repository category | Owned structures and verbs | +|---|---|---| +| `@deepseek-ai/dsh-goal` | `packages/goal/goal/`, domain service | Owns `GoalId`, compare-and-set `GoalRef`, `GoalSnapshot`, four-state `GoalPhase`, structured `GoalBlockReason`, process-local `GoalActivation`, replay folding, and `get`, `create`, `edit`, `pause`, `resume`, `complete`, `block`, `clear`, and `disarm` verbs. | +| `@deepseek-ai/dsh-tool-goal` | `packages/goal/tool-goal/`, model-facing consumer | Registers exclusive `get_goal`, `create_goal`, and `update_goal`; authenticates live turn provenance and narrows autonomous-round authority to completion or blocking reports with machine-routable reason codes. | +| `@deepseek-ai/dsh-goal-session` | `packages/goal/goal-session/`, continuation policy | Reserves, fences, admits, attributes, settles, cancels, and quiescently drains same-session goal rounds without importing the concrete loop. | +| `@deepseek-ai/dsh-commands` | `packages/ui/commands/`, UI registry | Owns `CommandDefinition`, discovery, scoped registration, direct dispatch, `CommandResult`, and request cancellation for human-only commands. | +| `@deepseek-ai/dsh-command-goal` | `packages/goal/command-goal/`, human-command producer | Registers `/goal` status, creation, edit, pause, resume, and clear over the goal domain for TUI and ACP. | +| `@deepseek-ai/dsh-tool-ralph` | `packages/workflow/tool-ralph/`, fixed workflow consumer | Registers `ralph({ objective, maxRounds? })`, validates the fresh structured provider and bounded `RalphRoundReport`, and returns `complete`, `blocked`, or `budget-limited`. | + +The detailed contracts live in the [goal-domain](2026-07-19-persisted-same-session-goal-domain.md), [model goal-tools](2026-07-19-model-facing-goal-tools.md), [goal-round driver](2026-07-19-same-session-goal-round-driver.md), [command registry](2026-07-19-plugin-command-registration.md), [human goal-command](2026-07-19-human-goal-command.md), and [Ralph workflow-tool](2026-07-19-fresh-agent-ralph-workflow-tool.md) Agent Notes. + +### Durable goal state and live authority + +One session has at most one current goal. Every non-clear mutation appends a full, versioned, model-visible goal snapshot through `Agent.inject()`; clear appends a revisioned tombstone. The session log is the only durable source of truth, so normal persistence, resume, compaction semantics, and `SessionStore.fork()` carry the goal without a second database or an artificial cancellation record. + +Durable phases are only `active`, `paused`, `blocked`, and `complete`. A blocked goal carries a required `GoalBlockReason` with a stable lower-kebab-case `code` and a non-empty human-readable `message`; usage limits, round exhaustion, model failures, and policy rejection are reason codes rather than extra lifecycle phases. Separate activation is `armed` or `disarmed` and is never persisted. Creation and explicit resume arm a goal; stop transitions, session start, fork replay, driver replacement, and driver teardown leave it disarmed. + +This separation makes session restoration observable and unsurprising. Reopening a session never starts goal work by itself. A later human prompt such as “continue”, “resume the goal”, or an equivalent request in any language gives the runtime-root model a new turn in which it may read the goal and call `update_goal(..., action: 'resume')`. `/goal resume` is the direct human-command path. The runtime authenticates that the request came from a live direct-human turn; prompt policy lets the model interpret whether the wording semantically authorizes creation or resumption. + +Forked sessions inherit the durable goal prefix because that is the natural replay result. The fork starts disarmed, so inheritance does not imply execution authority and no synthetic goal cancellation is inserted into history. + +`defaultMaxGoalRounds` is configurable and defaults to `256`. The cap counts only admitted goal rounds. `blockedAfterConsecutiveRounds` is separately configurable in the model-tool policy and defaults to `3`; it is a mechanical lower bound before an autonomous round may report a repeated blocker, not an evaluator of semantic sameness. + +### Same-session continuation + +The goal-round driver owns at most one pending reservation per exact live agent. It admits a reservation only when the goal is active and armed, the agent is idle, no competing human work exists, pending mutations are durable, the exact goal id/revision/round still matches, and downstream prompt policy accepts it. The prompt-submit fence checks those facts both before and after asynchronous listeners, preventing an edit, pause, human message, or unload race from admitting obsolete work. + +Only the durable goal-sourced `user/message` charges a round. Stale reservations become rejected zero-step turns without consuming the cap. A concurrent goal revision wins over settlement from an older round. + +Normal turn completion schedules another round only while the goal remains active, armed, and below its cap. Cancellation pauses. Rate limiting or quota exhaustion blocks with code `usage-limited`; cap exhaustion blocks with `round-limit`; queue failure uses `queue-failed`; turn errors, max-token stops, policy rejection, and unknown terminal results use their corresponding blocker codes. An independently composed request-recovery plugin may retry transient provider failures within that same turn; the goal driver never invents another round after an abnormal terminal outcome. A human can later authorize resume through ordinary language or `/goal resume`. + +### Human and model surfaces + +The human UX follows the compact Codex shape in the [public OpenAI Codex TUI dispatcher at commit `678157a`](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805): `/goal` shows status, `/goal ` creates, and `edit`, `pause`, `resume`, or `clear` perform direct lifecycle actions. The commit permalink keeps the researched grammar verifiable as Codex evolves. Status includes durable phase, admitted/capped rounds, and live armed/disarmed activation. Direct status and command output do not enter model history; accepted domain mutations remain reconstructable because the goal service records them. + +The model receives only `get_goal`, `create_goal`, and `update_goal`. It may create a goal when a direct human request clearly asks for substantial multi-round work, and it may infer that intent in any language. It must not turn routine one-turn work into a goal. Direct-human provenance is enforced in code; semantic interpretation remains model judgment. An autonomous goal round may report `complete` or `blocked` for the exact current goal round but cannot edit, pause, resume, or replace the human objective. + +TUI and ACP mount the shared command registry and complete goal stack by default and expose `/goal` through one producer. Every effective registered command is discoverable and invocable through every composed command adapter; a plugin incompatible with an application omits its command producer from that composition rather than relying on registry-level surface masks. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. The headless CLI and JSON-RPC front doors do not consume the command plane; ordinary human text can still authorize model goal tools when that stack is composed. + +### Fresh-agent Ralph execution + +Ralph is a first-class model tool in its own plugin, demonstrating that a sophisticated fixed execution policy can be composed without a new loop core. The plugin owns a fixed workflow script over `ctx.workflows` and `ctx.subagents`; it does not create session-goal state or add a branch to `dsh-agent-loop`. + +Each round uses an explicit `WorkflowStartRequest.subagentProvider`, defaulting to `spawn`. The provider must exist, support structured output, and declare that it does not inherit parent context. Ralph also passes its resolved round cap as `WorkflowStartRequest.maxTotalAgents`; the worker engine validates both per-run policies before publishing work, so provider misconfiguration or an engine ceiling below the requested Ralph scale fails before a run exists. The child inherits cwd and lineage but receives only the immutable objective, round/cap, workspace-as-authority instruction, and previous normalized report. + +A report contains status, summary, evidence, next steps, and blocker text. Status-specific invariants and serialized size are validated inside the fixed script and again at the consumer boundary. `maxRounds` is configurable, defaults to `256`, and is the ceiling for a call override. `maxHandoffChars` defaults to `16384`; oversized reports fail rather than being silently truncated. `maxResultChars` separately defaults to `16384` and bounds the complete successful parent-facing text, including its envelope and truncation marker. + +An ordinary child failure ends the run without retry. The fixed script reports the failed round and last successful handoff when one exists, and the tool returns that state as an error instead of misclassifying it as a malformed report or budget exhaustion. Fatal workflow infrastructure failures can settle before the script returns that state; richer reason transport and retry policy remain deferred. + +The tool is foreground and process-local. The parent tool call waits for the terminal result, propagates cancellation into the worker engine, and awaits `run.dispose()` so child work is quiescent before return. The model sees one call and one bounded successful terminal result or an error; completion and blocker envelopes explicitly say that a worker reported the outcome rather than presenting it as independent certification. Intermediate child conversations remain outside the parent transcript. + +### External design lineage + +Codex provides the minimal observable goal UX used here: a persistent chat-attached target with set, view, edit, pause, resume, and clear controls. This implementation adopts that discoverability while using this repository's event-sourced goal record, plugin scopes, and runtime authority checks. + +Current [Claude Code goals](https://code.claude.com/docs/en/goal) reinforce the distinction between a goal that starts another turn after the previous turn and a timed `/loop`. Claude Code also uses a separate small-model evaluator after each turn. This implementation adopts the policy distinction but intentionally does not copy that evaluator: evaluator inputs, tool access, deterministic checks, provider choice, isolation, and authority need a separately designed plugin contract rather than an implicit self-certification layer. + +External products are comparators, not compatibility targets. The local source studies informed the boundaries, while the shipped interfaces follow this repository's “everything is a plugin”, model-visible-is-logged, explicit default resolution, and quiescent teardown rules. + +### Verification + +The six owning Agent Notes record unit, integration, process, snapshot, cancellation, replay, and built-runtime coverage. The stack exercises strict goal-record folding, compare-and-set races, session fork inheritance, disarmed restoration, natural-language direct-human authority, configurable caps and blocked thresholds, exact goal-round attribution, adapter-wide command discovery, and transcript isolation. Shipped keyless snapshots cover model goal creation/inspection through the headless app, multi-round same-session lifecycle and cancellation through ACP, direct `/goal` status without a model turn, and two real Ralph rounds through the headless app. The Ralph snapshot boots the worker-thread engine, spawn provider, structured-output runtime, and agent loop, then inspects distinct unseeded child logs and exact one-way bounded handoff while pinning the parent stream. Focused real-stack tests additionally cover completion, blocker and round-limit outcomes, malformed and oversized reports, ordinary child failure with the last good handoff, one phase event, and cancellation to child quiescence. Package sources remain under the repository's per-file 100% coverage gate, and built-binary tests cover installed-artifact resolution. The implementation experience is recorded in the root testing policy: every non-trivial model- or human-visible change must carry a real-example keyless snapshot in the same PR rather than relying on package-only or mock-only fixture coverage. + +## Alternatives considered + +- **Implement the original universal loop capability seam** — rejected because `Evaluator`, `BudgetPolicy`, `RoundHandoff`, `GoalReflector`, background task ownership, persistence, and scheduling do not form one coherent mandatory abstraction. Building all of them before their first concrete consumers would create broad speculative surface and duplicate existing session, workflow, subagent, and task machinery. +- **Implement only same-session goals** — rejected because fresh-context iteration is materially different and is a valuable demonstration of the plugin architecture. Ralph belongs as a fixed workflow consumer with explicit context reset. +- **Put Ralph inside the goal-round driver** — rejected because same-session goals deliberately preserve one conversation while Ralph deliberately removes it. Combining them would make activation, replay, handoff, and UI state ambiguous. +- **Treat a fork as a fresh Ralph child** — rejected because a fork carries a conversation prefix. Fresh children plus workspace state and one explicit report are easier to bound and replay without a synthetic cancel record. +- **Copy Claude Code's evaluator into the first goal implementation** — rejected because a transcript-only model evaluator is one useful policy, not a generally trustworthy completion certificate. Deterministic evaluation and isolation must remain possible, so the evaluator is deferred until its authority and provider seam are designed. +- **Automatically continue after session restore** — rejected because opening a session is observation, not authority to spend resources. Durable state is restored while activation waits for a new human prompt. +- **Route `/goal` through the model** — rejected because status and explicit lifecycle controls should be deterministic, token-free UI actions; ordinary natural-language prompts remain the semantic model path. +- **Modify the concrete agent loop with goal or Ralph modes** — rejected because public queue, prompt, session, cancellation, workflow, and subagent seams already support both policies. The generic cancel-requested observation is the only core coordination addition. + +## Consequences + +- Goal-based execution ships without one overloaded “loop” object: same-session continuation and fresh-agent iteration have explicit, separately testable contracts. +- Durable goal history is replayable and forkable, while process-local activation prevents accidental work on resume. +- Humans receive a small Codex-shaped UX; models receive a compact provenance-checked tool surface; deployments can remove either independently. +- Ralph demonstrates a nontrivial fixed policy entirely as a plugin over existing workflow and subagent primitives. +- Round limits are generous by default but remain deployment-controlled. They bound iterations, not tokens, price, elapsed time, or external side effects. +- The original proposal's evaluator, budget, reflector, background-task, CLI, and generic loop-session architecture is intentionally not part of the implemented public surface. + +## Known limitations and deferred work + +- **Independent evaluation** — same-session completion/blocking and Ralph terminal status are model or worker declarations. A separate evaluator, evaluator-driven feedback round, completion certificate, deterministic checker, adversarial verifier, and criteria/executor/isolation contract remain deferred. +- **Aggregate budgets** — `maxGoalRounds` and Ralph `maxRounds` are the only aggregate effort limits. Token, currency, elapsed-time, provider-usage, and per-round price admission policies are absent. +- **No persistent autonomous runner** — same-session goal facts persist, but activation and scheduling are process-local and deliberately wait for human input after restore. Ralph runs are foreground and cannot resume after process loss. Background collection, restart recovery, and unattended resident execution are deferred. +- **No time scheduler** — interval `/loop`, cron, proactive maintenance, and cloud or desktop scheduling are outside this decision. +- **No generic loop journal or execution-world rewind** — session replay reconstructs model-visible goal history, not prior files, processes, environment, credentials, or external side effects. Ralph treats the current workspace as authority and carries no cross-run journal. +- **No goal reflector** — concern events, automatic no-progress heuristics, goal revision by an independent reflector, stuck-pattern detection, and `loop_split` are not implemented. Humans can edit, pause, clear, or resume the goal directly. +- **Ralph policy remains narrow** — one round creates one fresh child; within-round fan-out, evaluator/worker role separation, dynamic provider/model selection, and structural recursive-Ralph tool denial need separate policy surfaces. Prompt guidance is not enforcement. +- **Ralph does not retry a failed child** — an ordinary failure preserves the failed round and last good handoff, while fatal workflow infrastructure failures can end before that state is available. Retry count, backoff, and richer failure transport need separate policy and seam design. +- **Portable UI remains modest** — TUI and ACP render plain-text goal status and generic Ralph cards. There is no continuous status widget, reconnectable command output, modal goal editor, or command plane in the headless CLI or JSON-RPC front doors. diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md new file mode 100644 index 0000000000..284e73051e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md @@ -0,0 +1,129 @@ +# Agent Note: Harness 层目标式执行 + +Status: implemented + +[English](2026-07-16-harness-level-loop.md) | 中文 + +## 问题 + +具体 agent loop 只拥有一个 Turn:它排空已接纳输入,执行一个或多个模型与工具 Step,然后停止。大型目标通常需要一项外层策略来开始另一个 Turn、保留进度、在预算处停止,并让人类能够理解其状态。定时提示词、同会话续行和全新 agent Ralph 尝试都会重复工作,但它们并不共享相同的状态、权限、记忆或生命周期。 + +若把每种重复动作都称为一个通用“loop”,就会掩盖这些差异。同会话工作必须在现有转录中持久化人类目标,同时保留对话上下文。Ralph 工作必须有意丢弃对话上下文,只使用工作区和一份有界交接。面向人类的状态不能暗示重新打开会话就会静默授权更多工作。完成与阻塞声明也需要显式信任边界,而不能被偷渡进调度器抽象。 + +因此,本仓库需要位于 Turn/Step loop 之上的目标式执行,但不需要一个把持久化、评估、预算、调度、交接、后台任务和 UI 组合在一起的推测性通用 loop 服务。 + +## 决策 + +本提案以修订后的形式实现为构建在现有接缝之上的两项显式插件策略: + +1. **同会话目标**在当前会话中保留一个持久目标,并且只在实时激活态已激活时接纳带目标归属的续行 Turn。 +2. **全新 agent Ralph 运行**执行一个固定前台工作流,其中每个 Round 都生成一个不带对话种子的全新结构化子 agent。 + +系统中没有 `packages/loop/` 包族、`LoopDriver`、`LoopId`、通用 `StopCondition` 或面向模型的通用 `loop` 工具。两项策略共享本仓库普通的 agent、session、tools、workflow、subagent 与 UI 扩展接缝,但不会假装一种生命周期可以同时适配两者。 + +### 词汇与策略边界 + +同会话层级是 **Goal → Goal Round → Turn → Step**。一个 Goal Round 是为当前目标接纳的一次续行周期,并实体化为一个带目标来源的 Turn。同一会话中的人类 Turn 或无关 Turn 不会消耗目标回合上限,而一个 Turn 仍可包含多个模型/工具 Step。 + +全新 agent 层级是 **Ralph Run → Ralph Round → fresh child Turn → Step**。一个 Ralph Round 创建一个子会话。父转录和此前子转录都不是种子上下文;共享工作区与一份有界结构化报告承载跨 Round 状态。 + +因此,“Round”是外层策略迭代,不是每个会话 Turn 的同义词。具体 `dsh-agent-loop` 仍是 Turn/Step 引擎。同会话驱动器使用公开 agent 与 session 事件;它对核心唯一的新增项是通用的取消前观察通知 `agent/cancel-requested`,任何需要安全收敛取消的生命周期策略都可以使用它。 + +基于时间的 `/loop` 或定时执行是第三种策略,本决策不实现它。它应归属于调度器,而不是任一目标包族。 + +### 包拓扑与所属动词 + +| 包 | 仓库类别 | 所属结构与动词 | +|---|---|---| +| `@deepseek-ai/dsh-goal` | `packages/goal/goal/`,领域服务 | 拥有 `GoalId`、比较并交换 `GoalRef`、`GoalSnapshot`、四状态 `GoalPhase`、结构化 `GoalBlockReason`、进程本地 `GoalActivation`、重放折叠,以及 `get`、`create`、`edit`、`pause`、`resume`、`complete`、`block`、`clear` 与 `disarm` 动词。 | +| `@deepseek-ai/dsh-tool-goal` | `packages/goal/tool-goal/`,面向模型消费者 | 注册互斥的 `get_goal`、`create_goal` 与 `update_goal`;认证实时 Turn 来源,并把自治 Round 权限收窄到带机器可路由原因代码的完成或阻塞报告。 | +| `@deepseek-ai/dsh-goal-session` | `packages/goal/goal-session/`,续行策略 | 在不导入具体 loop 的情况下,预留、设围栏、接纳、归属、结算、取消并静止排空同会话目标回合。 | +| `@deepseek-ai/dsh-commands` | `packages/ui/commands/`,UI 注册表 | 拥有面向人类专用命令的 `CommandDefinition`、发现、作用域注册、直接分发、`CommandResult` 与请求取消。 | +| `@deepseek-ai/dsh-command-goal` | `packages/goal/command-goal/`,人类命令生产方 | 为 TUI 和 ACP 注册构建在目标领域之上的 `/goal` 状态、创建、编辑、暂停、恢复与清除。 | +| `@deepseek-ai/dsh-tool-ralph` | `packages/workflow/tool-ralph/`,固定工作流消费者 | 注册 `ralph({ objective, maxRounds? })`,验证全新结构化 provider 与有界 `RalphRoundReport`,并返回 `complete`、`blocked` 或 `budget-limited`。 | + +详细契约分别由[目标领域](2026-07-19-persisted-same-session-goal-domain.md)、[模型目标工具](2026-07-19-model-facing-goal-tools.md)、[目标回合驱动器](2026-07-19-same-session-goal-round-driver.md)、[命令注册表](2026-07-19-plugin-command-registration.md)、[人类目标命令](2026-07-19-human-goal-command.md)与 [Ralph 工作流工具](2026-07-19-fresh-agent-ralph-workflow-tool.md) Agent Note 拥有。 + +### 持久目标状态与实时权限 + +一个会话至多有一个当前目标。每次非清除变更都通过 `Agent.inject()` 追加一份完整、带版本且模型可见的目标快照;清除会追加带修订号的墓碑。会话日志是唯一持久事实来源,因此普通持久化、恢复、压缩语义与 `SessionStore.fork()` 会携带目标,无需第二个数据库或人为取消记录。 + +持久阶段只有 `active`、`paused`、`blocked` 与 `complete`。阻塞目标必须携带 `GoalBlockReason`,其中包含稳定的小写 kebab-case `code` 与非空的人类可读 `message`;用量限制、Round 耗尽、模型失败与策略拒绝都是原因代码,而不是额外生命周期阶段。独立激活态是 `armed` 或 `disarmed`,且永不持久化。创建与显式恢复会激活目标;停止转换、会话启动、fork 重放、驱动器替换和驱动器拆卸都会让目标保持未激活。 + +这种分离让会话恢复可观察且符合直觉。重新打开会话绝不会自行开始目标工作。随后的人类提示词,例如“继续”、“恢复目标”或任何语言中的等价请求,会给运行时根 agent 的模型一个新 Turn;模型可在其中读取目标并调用 `update_goal(..., action: 'resume')`。`/goal resume` 是直接人类命令路径。运行时认证请求来自实时直接人类 Turn;提示策略让模型解释措辞在语义上是否授权创建或恢复。 + +fork 会话会继承持久目标前缀,因为这是自然的重放结果。fork 从未激活状态开始,因此继承不等于执行权限,历史中也不会插入合成目标取消。 + +`defaultMaxGoalRounds` 可配置且默认为 `256`。该上限只计算已接纳目标回合。`blockedAfterConsecutiveRounds` 在模型工具策略中单独配置且默认为 `3`;它只是在自治 Round 报告重复阻塞前的机械下限,不是对语义相同性的评估器。 + +### 同会话续行 + +目标回合驱动器为每个准确实时 agent 至多拥有一个待定预留。只有目标处于活跃且已激活状态、agent 空闲、不存在竞争性人类工作、待定变更已经持久、准确目标 id/修订号/Round 仍匹配,并且下游提示词策略接受时,它才会接纳预留。prompt-submit 围栏在异步监听器前后都检查这些事实,防止编辑、暂停、人类消息或卸载竞争接纳过时工作。 + +只有持久的目标来源 `user/message` 会计入一个 Round。过时预留会成为未消耗上限的零 Step 拒绝 Turn。并发目标修订会胜过旧 Round 的结算。 + +普通 Turn 完成后,只有目标仍活跃、已激活且低于上限时才会安排另一个 Round。取消会暂停。速率限制或配额耗尽以代码 `usage-limited` 阻塞;上限耗尽使用 `round-limit`;队列失败使用 `queue-failed`;Turn 错误、max-token 停止、策略拒绝与未知终止结果使用各自对应的阻塞代码。独立组合的请求恢复插件可以在同一个 Turn 内重试暂时性 provider 失败;目标驱动器绝不会在异常终止结果后凭空发起另一个 Round。人类随后可以通过普通语言或 `/goal resume` 授权恢复。 + +### 人类与模型表面 + +人类 UX 遵循 [OpenAI Codex 在提交 `678157a` 时的公开 TUI 分发器](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805)中的紧凑形态:`/goal` 显示状态,`/goal ` 创建目标,而 `edit`、`pause`、`resume` 或 `clear` 执行直接生命周期操作。该提交永久链接让研究所得语法在 Codex 演进时仍可验证。状态包含持久阶段、已接纳/上限 Round 数以及实时已激活/未激活状态。直接状态与命令输出不会进入模型历史;已接受领域变更仍可重建,因为目标服务会记录它们。 + +模型只接收 `get_goal`、`create_goal` 和 `update_goal`。当直接人类请求清楚要求大量多 Round 工作时,模型可以创建目标,并且可以从任何语言推断该意图。它不得把日常单 Turn 工作变成目标。直接人类来源由代码强制执行;语义解释仍是模型判断。自治目标 Round 可以为准确当前目标 Round 报告 `complete` 或 `blocked`,但不能编辑、暂停、恢复或替换人类目标。 + +TUI 与 ACP 默认挂载共享命令注册表和完整目标栈,并通过同一个生产方暴露 `/goal`。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI agent spine 要求显式选择加入,以免单次调用方静默变成多 Round 操作。无头 CLI 与 JSON-RPC 前端不消费命令平面;挂载目标栈后,普通人类文本仍可授权模型目标工具。 + +### 全新 agent Ralph 执行 + +Ralph 是位于自有插件中的一等模型工具,展示了复杂固定执行策略可以在没有新 loop 核心的情况下组合完成。该插件拥有构建在 `ctx.workflows` 与 `ctx.subagents` 之上的固定工作流脚本;它不会创建会话目标状态,也不会为 `dsh-agent-loop` 增加分支。 + +每个 Round 都使用显式 `WorkflowStartRequest.subagentProvider`,默认为 `spawn`。该 provider 必须存在、支持结构化输出,并声明不继承父上下文。Ralph 还会把解析后的 Round 上限作为 `WorkflowStartRequest.maxTotalAgents` 传递;工作线程引擎会在发布工作前验证两项每次运行策略,因此 provider 配置错误或低于所请求 Ralph 规模的引擎上限会在运行存在前失败。子 agent 继承 cwd 与谱系,但只接收不可变目标、当前 Round/上限、以工作区为权威的指令和上一份规范化报告。 + +报告包含状态、摘要、证据、下一步与阻塞文本。固定脚本内部和消费者边界都会验证状态专用不变量与序列化大小。`maxRounds` 可配置,默认为 `256`,并作为调用覆盖值的上限。`maxHandoffChars` 默认为 `16384`;过大报告会失败,而不会被静默截断。`maxResultChars` 单独默认为 `16384`,并限制面向父级的完整成功文本,包括外层文本与截断标记。 + +普通子 agent 失败会结束运行且不重试。固定脚本会报告失败 Round,并在存在时带回上一份成功交接;工具会把该状态作为错误返回,而不会误判为畸形报告或预算耗尽。致命工作流基础设施错误可能在脚本返回该状态前结算;更丰富的原因传输与重试策略均予以延期。 + +该工具位于前台且只存在于进程内。父工具调用等待终止结果,把取消传播到工作线程引擎,并等待 `run.dispose()`,因此返回前子工作已达到静止。模型只看到一次调用,以及一份有界成功终止结果或一个错误;完成与阻塞的外层文本会明确说明结果由工作者报告,而不会呈现为独立认证。中间子 agent 对话不会进入父转录。 + +### 外部设计谱系 + +Codex 提供了这里采用的最小可观察目标 UX:一个附着于聊天的持久目标,以及设置、查看、编辑、暂停、恢复与清除控制。本实现采用这种可发现性,但使用本仓库的事件溯源目标记录、插件作用域与运行时权限检查。 + +当前 [Claude Code goals](https://code.claude.com/docs/en/goal) 进一步验证了“前一 Turn 后启动另一 Turn 的目标”和定时 `/loop` 之间的区别。Claude Code 还会在每个 Turn 后使用独立小模型评估器。本实现采用策略区分,但有意不复制该评估器:评估器输入、工具访问、确定性检查、provider 选择、隔离与权限需要单独设计的插件契约,而不是隐式自我认证层。 + +外部产品只是比较对象,不是兼容目标。本地源码研究帮助确定边界,而交付接口遵循本仓库“一切皆插件”、模型可见即可记录、显式解析默认值与静止拆卸规则。 + +### 验证 + +六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、准确目标回合归属、适配器范围的命令发现与转录隔离。已发布的无密钥快照覆盖通过无头应用创建/检查模型目标、通过 ACP 执行多 Round 同会话生命周期与取消、无需模型 Turn 的直接 `/goal` 状态,以及通过无头应用执行两个真实 Ralph Round。Ralph 快照会启动工作线程引擎、spawn provider、结构化输出运行时与 agent loop,随后检查互不相同且无种子的子日志和准确单向有界交接,同时固定父级事件流。聚焦的真实栈测试还覆盖完成、阻塞与 Round 上限结果、畸形及过大报告、保留上一份有效交接的普通子 agent 失败、单个阶段事件,以及取消后达到子 agent 静止状态。包源码继续受仓库逐文件 100% 覆盖率门禁约束,构建后二进制测试覆盖已安装产物解析。实现经验已记录进根测试策略:每项非平凡的模型或人类可见变更都必须在同一 PR 中携带真实示例无密钥快照,而不能依赖仅包级或仅模拟夹具的覆盖。 + +## 考虑过的替代方案 + +- **实现原始通用 loop 能力接缝**——不予采纳,因为 `Evaluator`、`BudgetPolicy`、`RoundHandoff`、`GoalReflector`、后台任务所有权、持久化与调度并不构成一项一致的必选抽象。在出现首个具体消费者前全部构建,会产生宽泛推测性表面,并重复现有 session、workflow、subagent 与 task 机制。 +- **只实现同会话目标**——不予采纳,因为全新上下文迭代在实质上不同,也是插件架构的重要示范。Ralph 应作为带显式上下文重置的固定工作流消费者。 +- **把 Ralph 放进目标回合驱动器**——不予采纳,因为同会话目标有意保留一段对话,而 Ralph 有意移除对话。合并两者会让激活、重放、交接与 UI 状态含糊不清。 +- **把 fork 当成全新 Ralph 子 agent**——不予采纳,因为 fork 会携带对话前缀。全新子 agent 加工作区状态与一份显式报告更容易限制和重放,并且无需合成取消记录。 +- **把 Claude Code 评估器复制进首个目标实现**——不予采纳,因为只读取转录的模型评估器是一项有用策略,但不是普遍可信的完成证书。系统必须仍能支持确定性评估与隔离,因此评估器延期到其权限与 provider 接缝完成设计之后。 +- **会话恢复后自动续行**——不予采纳,因为打开会话是观察行为,不是花费资源的权限。系统恢复持久状态,而激活态等待新的人类提示词。 +- **通过模型路由 `/goal`**——不予采纳,因为状态与显式生命周期控制应是确定、零 token 的 UI 操作;普通自然语言提示词仍是语义模型路径。 +- **为具体 agent loop 增加目标或 Ralph 模式**——不予采纳,因为公开队列、提示词、会话、取消、工作流与 subagent 接缝已经支持两项策略。通用 cancel-requested 观察是唯一核心协调新增项。 + +## 后果 + +- 目标式执行在没有单个过载“loop”对象的情况下交付:同会话续行与全新 agent 迭代拥有显式、可独立测试的契约。 +- 持久目标历史可以重放和 fork,而进程本地激活态会防止恢复时意外开始工作。 +- 人类获得小型 Codex 形态 UX;模型获得紧凑、带来源检查的工具表面;部署可以独立移除任一能力。 +- Ralph 展示了非平凡固定策略可以完全作为现有 workflow 与 subagent 原语之上的插件实现。 +- Round 上限默认宽裕,但仍由部署控制。它限制迭代次数,不限制 token、价格、耗时或外部副作用。 +- 原始提案中的评估器、预算、反思器、后台任务、CLI 与通用 loop-session 架构有意不进入已实现公开表面。 + +## 已知限制与延期工作 + +- **独立评估**——同会话完成/阻塞和 Ralph 终止状态都是模型或工作者声明。独立评估器、评估器驱动反馈 Round、完成证书、确定性检查器、对抗式 verifier 与 criteria/executor/isolation 契约均予以延期。 +- **聚合预算**——`maxGoalRounds` 与 Ralph `maxRounds` 是唯一聚合工作量限制。token、货币、耗时、provider 用量与逐 Round 价格准入策略均不存在。 +- **没有持久自治运行器**——同会话目标事实会持久化,但激活与调度只存在于进程内,并且有意在恢复后等待人类输入。Ralph 位于前台,进程丢失后无法恢复。后台收集、重启恢复与无人值守常驻执行均予以延期。 +- **没有时间调度器**——间隔 `/loop`、cron、主动维护以及云端或桌面调度不在本决策范围内。 +- **没有通用 loop 日志或执行世界回退**——会话重放会重建模型可见目标历史,而不会恢复此前文件、进程、环境、凭据或外部副作用。Ralph 把当前工作区作为权威,并且没有跨运行日志。 +- **没有目标反思器**——concern 事件、自动无进展启发式、由独立反思器执行的目标修订、卡住模式检测与 `loop_split` 均未实现。人类可以直接编辑、暂停、清除或恢复目标。 +- **Ralph 策略仍然狭窄**——一个 Round 创建一个全新子 agent;Round 内扇出、评估器/工作者角色分离、动态 provider/模型选择与结构化递归 Ralph 工具禁止都需要独立策略表面。提示词指导不是强制执行。 +- **Ralph 不会重试失败的子 agent**——普通失败会保留失败 Round 与上一份有效交接,而致命工作流基础设施错误可能在该状态可用前结束。重试次数、退避与更丰富的失败传输需要独立的策略与接缝设计。 +- **可移植 UI 仍较朴素**——TUI 与 ACP 渲染纯文本目标状态和通用 Ralph 卡片。系统没有持续状态组件、可重连命令输出、模态目标编辑器,无头 CLI 与 JSON-RPC 前端也没有命令平面。 diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml new file mode 100644 index 0000000000..17e3cdf5e2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.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-fresh-agent-ralph-workflow-tool.md: c2db4d7dd30c27a25adecdfc425db261cc3dfeb5 +2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: e33e9848d71c98c8f83494ebe8bf171ef10b9305 diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md new file mode 100644 index 0000000000..c2db4d7dd3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md @@ -0,0 +1,74 @@ +# Agent Note: Fresh-agent Ralph workflow tool + +Status: implemented + +English | [中文](2026-07-19-fresh-agent-ralph-workflow-tool.zh.md) + +## Problem + +Same-session goals preserve conversation and let one agent continue a durable objective, while the general workflow tool lets the model write a fan-out orchestration script. Neither is the Ralph pattern: repeatedly give the same objective to a completely fresh worker, use the shared workspace as long-term memory, and carry only a small explicit handoff until work completes or a limit is reached. + +Adding Ralph behavior to `dsh-agent-loop`, the goal driver, or the public model-written workflow language would couple one policy to unrelated execution machinery. Letting each child inherit the parent conversation would also defeat context reset and make replay depend on a growing implicit prefix. The feature needs a fixed, reviewable policy built from existing plugin primitives, with cancellation quiescence, bounded cross-round data, a generous configurable cap, and no novel human-facing goal state. + +## Decision + +Add `@deepseek-ai/dsh-tool-ralph` as a separate consumer package under `packages/workflow/`. It registers `ralph({ objective, maxRounds? })`, owns a fixed workflow script, and depends only on `ctx.tools`, `ctx.systemPrompt`, `ctx.workflows`, and `ctx.subagents`. A Ralph run is not a session goal, creates no goal state, and requires no branch in the concrete agent loop. + +The tool is foreground-only. The calling agent parents every child for cwd and lineage, the parent tool call waits for the complete run, and the parent step's abort signal cancels the workflow. `run.dispose()` is awaited on every path, so cancellation reaches the worker engine's bounded settlement and child quiescence before the call returns. + +### Per-run workflow provider route + +`WorkflowStartRequest` gains optional `subagentProvider`. The worker-thread engine resolves that explicit per-run value before falling back to its configured provider, requires the selected normalized route to be registered before publishing the run, and uses it for every `agent()` call. The script cannot observe or replace this route. The ordinary `workflow` tool leaves the field unset and exposes no new model argument, so general workflow behavior and provider policy stay unchanged. + +The Ralph plugin's `subagentProvider` defaults to `spawn`. Immediately before a call it requires the named provider to exist, support structured output, and report `inheritsParentContext: false`; a fork-like or incapable provider fails loudly before workflow start. Provider lookup remains call-time because effect-scoped provider registration can change under HMR. + +### Per-run workflow child ceiling + +`WorkflowStartRequest` also gains optional `maxTotalAgents`. The worker-thread engine requires a positive safe integer no greater than its configured deployment ceiling and installs the resolved value in that run's worker limits before publishing the run. Ralph passes its resolved `maxRounds` as this ceiling, so the fixed loop's round budget and the generic runaway-child backstop cannot disagree. The ordinary workflow tool leaves the field unset and keeps the engine default. + +### Ralph rounds and handoff + +The hierarchy is Ralph Run → Ralph Round → fresh child Turn → Step. One Ralph round creates exactly one child through the selected provider. Spawn gives that child a distinct session with no seed while preserving the parent's cwd, so the shared working tree is the durable authority and neither parent conversation nor prior child history enters the request. + +The fixed prompt passes only the immutable objective, current round and cap, a workspace-as-authority instruction, and the previous structured report. A `RalphRoundReport` contains `status: continue | complete | blocked`, `summary`, `evidence`, `nextSteps`, and `blocker`. Strings must be normalized; `continue` requires next steps and no blocker, `complete` requires evidence with no next steps or blocker, and `blocked` requires a concrete blocker. The script validates semantics and serialized size before the report can become the next handoff; the consumer validates the materialized terminal value again across the workflow seam. + +`maxRounds` defaults to `256` and is also the deployment ceiling for a call override. `maxHandoffChars` and `maxResultChars` each default to `16384`. All are positive safe-integer config values. Oversized handoffs fail rather than being silently truncated; `maxResultChars` separately bounds the complete successful parent-facing text, including its envelope and truncation marker, without changing cross-round state. After a `continue` report at the last permitted round, the fixed script returns `budget-limited`; `complete` and `blocked` return immediately with the final report and number of rounds started. + +The workflow language maps a normally settled but unsuccessful child to `null`. The fixed script detects that value before report validation and returns `round-failed` with the failed round plus the last successful handoff when one exists; the tool turns it into an error instead of misclassifying it as a malformed report or budget exhaustion. Ralph adds no retry policy. Fatal provider-start, transport, worker, and workflow errors remain generic workflow failures because the workflow seam does not carry a recoverable child report on those paths. + +### Model and UI surface + +The model may supply only `objective` and optional `maxRounds`; provider selection, report schema, handoff cap, and script are deployment-owned. A fixed prompt section says to use `ralph` only when the direct human explicitly asks for Ralph or fresh-agent iteration, and distinguishes it from same-session goals, bounded delegation, and general fan-out workflows. This is guidance rather than a new goal UX state machine. + +ACP and terminal presentation use a generic `ralph` card whose raw input is the objective. Successful completion and blocker envelopes say that a worker reported the outcome rather than presenting it as independent certification. The parent transcript retains the original tool call and one bounded successful terminal report or an error, not intermediate child messages. Shipped headless, TUI, and ACP compositions load the plugin beside the existing workflow engine; JSON-RPC remains unchanged because its default composition does not expose workflows. + +## Testing + +Unit tests cover config and call-cap resolution, provider capability rejection, fixed start-request routing and child ceiling, all successful terminal outcomes, ordinary child-failure envelopes, malformed and oversized boundary values, exact successful-result truncation, abort timing, disposal, render intent, prompt lifecycle, and namespace-plugin shape at per-file 100% coverage. Worker-engine tests prove synchronous provider-route validation, per-run child ceilings below the deployment ceiling, and that a provider override selects every child without changing the configured default, including the built `lib/worker.cjs` under plain Node. + +A keyless real-stack integration drives the fixed script through the actual worker-thread engine, spawn provider, structured-output runtime, and agent loop. It proves distinct child identities, absent `seedLength`, inherited cwd, no parent-history markers in either child request, exact previous-report handoff only in the following round, one phase event, terminal completion, and disposal of both children. The same real stack covers blocker and round-limit outcomes, unnormalized and semantically invalid reports, oversized handoffs, ordinary child failure with the last good handoff, and cancellation to child quiescence. A shipped keyless headless snapshot additionally boots the real `examples/headless-agent` composition, invokes `ralph`, pins the parent stream transcript, and inspects persisted logs for two distinct unseeded child sessions and the round-one handoff appearing only in round two. Tool tests pin generic call/result presentation, while ACP replay header snapshots pin the shipped schema and prompt-guidance transcript surface. + +## Alternatives considered + +- **Put Ralph in the same-session goal driver** — rejected because goal rounds intentionally preserve one conversation, while Ralph's defining property is a fresh context per round; combining them would make goal lifecycle and child orchestration inseparable. +- **Expose a `fresh` or loop flag on the general workflow tool** — rejected because the model-written script surface should remain general and provider-neutral; Ralph's fixed report protocol and stop policy deserve one reviewable consumer. +- **Use `subagent_fork` for replay convenience** — rejected because inherited completed turns are implicit, growing handoff state and violate the fresh-context contract. The workspace plus one structured report is replayable without inserting artificial cancellation records. +- **Call the subagent seam directly from the tool** — rejected because the existing workflow engine already owns foreground orchestration, structured children, cancellation propagation, worker termination, events, and quiescent disposal. Reusing it demonstrates plugin composition instead of building a second loop runtime. +- **Silently truncate a large report** — rejected because truncation can remove status evidence or next steps while still looking like an authoritative handoff. A producer must emit a valid report within the configured bound. + +## Consequences + +- Fresh-agent iteration is a first-class model tool implemented entirely as a removable plugin over existing seams. +- Goal rounds and Ralph rounds stay different concepts: the former is one same-session continuation turn, while the latter is one fresh child inside a foreground workflow. +- The workspace becomes authoritative cross-round memory, so workers must inspect and verify it rather than trusting a narrative handoff. +- A generous round ceiling permits substantial autonomous work, while deployment config still bounds child count and every handoff remains size-limited. +- Provider routing and a lowerable per-run child ceiling become explicit workflow start concerns without expanding the script or ordinary workflow tool surface. + +## Known limitations and deferred work + +- Completion and blocker status are worker self-declarations. An independent evaluator, evaluator-driven feedback round, completion certificate, or adversarial verifier is intentionally deferred. +- Runs are foreground and process-local. Background collection, persistence/resume, scheduling, and restart recovery are absent. +- Round count is the only aggregate budget. Token, currency, elapsed-time, and provider-usage budgets remain separate future policy. +- One round creates one child. Within-round fan-out, evaluator/worker role separation, dynamic provider or model selection, and cross-run journals are deferred. +- An ordinary child failure ends the run without retry, while preserving the failed round and last successful handoff. Fatal workflow infrastructure failures can end before the fixed script returns that state; adding retry or richer failure transport requires separate policy and seam design. +- Prompt guidance asks models not to invoke Ralph recursively; a structural child-tool restriction would require a separately designed workflow child-policy surface. diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md new file mode 100644 index 0000000000..e33e9848d7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md @@ -0,0 +1,74 @@ +# Agent Note: 全新 agent Ralph 工作流工具 + +Status: implemented + +[English](2026-07-19-fresh-agent-ralph-workflow-tool.md) | 中文 + +## 问题 + +同会话目标会保留对话,让一个 agent 持续完成持久目标;通用工作流工具则让模型编写扇出编排脚本。两者都不是 Ralph 模式:把同一目标反复交给完全全新的工作者,以共享工作区作为长期记忆,并且在各轮之间只传递一份小型显式交接,直到工作完成或触及限制。 + +如果把 Ralph 行为加入 `dsh-agent-loop`、目标驱动器或面向模型的公开工作流语言,就会让一项策略与无关的执行机制耦合。让每个子 agent 继承父对话也会破坏上下文重置,并让重放依赖不断增长的隐式前缀。此功能需要一项由现有插件原语组合而成的固定、可评审策略,同时具备取消静止性、有界跨轮数据、宽裕且可配置的上限,并且不引入新颖的面向人类目标状态。 + +## 决策 + +在 `packages/workflow/` 下新增独立消费者包 `@deepseek-ai/dsh-tool-ralph`。它注册 `ralph({ objective, maxRounds? })`,拥有固定工作流脚本,并且只依赖 `ctx.tools`、`ctx.systemPrompt`、`ctx.workflows` 和 `ctx.subagents`。Ralph 运行不是会话目标,不会创建目标状态,也不要求在具体 agent loop 中增加分支。 + +该工具仅以前台方式运行。调用 agent 作为每个子 agent 的父级以提供 cwd 和谱系,父工具调用等待整次运行结束,父步骤的中止信号会取消工作流。每条路径都会等待 `run.dispose()`,因此调用返回前,取消会经过工作流引擎的有界收敛并达到子 agent 静止状态。 + +### 每次运行的工作流 provider 路由 + +`WorkflowStartRequest` 新增可选的 `subagentProvider`。工作线程引擎先解析这个显式的每次运行值,再回退到引擎配置的 provider;在发布运行前,它要求所选规范化路由已注册,并把结果用于每次 `agent()` 调用。脚本无法观察或替换此路由。普通 `workflow` 工具不设置该字段,也不暴露新的模型参数,因此通用工作流行为和 provider 策略保持不变。 + +Ralph 插件的 `subagentProvider` 默认为 `spawn`。每次调用前,它要求具名 provider 已存在、支持结构化输出且报告 `inheritsParentContext: false`;类似 fork 或能力不足的 provider 会在工作流启动前响亮失败。provider 查找保留在调用期,因为效果作用域内的 provider 注册可能随 HMR 改变。 + +### 每次运行的工作流子 agent 上限 + +`WorkflowStartRequest` 还新增可选的 `maxTotalAgents`。工作线程引擎要求它是正安全整数且不高于已配置的部署上限,并在发布运行前把解析值装入该运行的工作线程限制。Ralph 把解析后的 `maxRounds` 作为此上限,因此固定循环的轮次预算不会与通用失控子 agent 后备限制冲突。普通工作流工具不设置该字段并保留引擎默认值。 + +### Ralph 轮次与交接 + +层级为 Ralph 运行 → Ralph 轮次 → 全新子 agent 回合 → 步骤。每个 Ralph 轮次恰好通过所选 provider 创建一个子 agent。Spawn 给该子 agent 一个没有种子的独立会话,同时保留父级 cwd,因此共享工作树是持久权威,父对话和先前子 agent 历史都不会进入请求。 + +固定提示只传递不可变目标、当前轮次与上限、以工作区为权威的指令,以及上一份结构化报告。`RalphRoundReport` 包含 `status: continue | complete | blocked`、`summary`、`evidence`、`nextSteps` 和 `blocker`。字符串必须规范化;`continue` 要求存在下一步且没有阻塞项,`complete` 要求存在证据且没有下一步或阻塞项,`blocked` 要求具体阻塞项。报告成为下一次交接前,脚本会验证语义与序列化大小;消费者还会跨工作流接缝再次验证实体化的终止值。 + +`maxRounds` 默认为 `256`,同时也是调用覆盖值的部署上限。`maxHandoffChars` 和 `maxResultChars` 均默认为 `16384`。三者都是正安全整数配置值。过大的交接会失败,而不会被静默截断;`maxResultChars` 单独限制面向父级的完整成功文本,包括外层文本和截断标记,并且不会改变跨轮状态。最后一个允许轮次报告 `continue` 后,固定脚本返回 `budget-limited`;`complete` 和 `blocked` 会立即返回最终报告与已启动轮次数。 + +工作流语言会把正常结束但未成功的子 agent 映射为 `null`。固定脚本会在报告验证前检测该值,并返回 `round-failed`,其中包含失败轮次,以及存在时的上一份成功交接;工具会把它转成错误,而不会误判为畸形报告或预算耗尽。Ralph 不添加重试策略。致命的 provider 启动、传输、工作线程和工作流错误仍是通用工作流失败,因为这些路径上的工作流接缝不携带可恢复的子报告。 + +### 模型与 UI 表面 + +模型只能提供 `objective` 和可选的 `maxRounds`;provider 选择、报告 schema、交接上限和脚本都由部署拥有。固定提示区段说明,只有直接人类明确要求 Ralph 或全新 agent 迭代时才使用 `ralph`,并将其与同会话目标、有界委派和通用扇出工作流区分开。这是指导,而不是新的目标 UX 状态机。 + +ACP 和终端展示使用通用 `ralph` 卡片,并把目标作为原始输入。成功完成与阻塞的外层文本会说明结果由工作者报告,而不会把它呈现为独立认证。父转录只保留原始工具调用,以及一份有界成功终止报告或一个错误,不包含中间子 agent 消息。发布的无头、TUI 与 ACP 组合会在现有工作流引擎旁加载该插件;JSON-RPC 保持不变,因为其默认组合不暴露工作流。 + +## 测试 + +单元测试覆盖配置与调用上限解析、provider 能力拒绝、固定启动请求路由与子 agent 上限、全部成功终止结果、普通子 agent 失败外层值、畸形及过大边界值、成功结果精确截断、中止时序、处置、渲染意图、提示生命周期和命名空间插件形状,并达到逐文件 100% 覆盖率。工作流引擎测试证明 provider 路由会同步验证、每次运行的子 agent 上限可低于部署上限,并且 provider 覆盖会选择每个子 agent 且不改变配置默认值,其中包括普通 Node 下构建后的 `lib/worker.cjs`。 + +一项无密钥真实栈集成测试通过实际工作线程引擎、spawn provider、结构化输出运行时和 agent loop 驱动固定脚本。它证明子 agent 标识不同、没有 `seedLength`、继承 cwd、两个子请求都不含父历史标记、上一份报告只精确出现在下一轮交接中、只产生一个阶段事件、终止完成以及两个子 agent 都被处置。同一真实栈还覆盖阻塞与轮次上限结果、未规范化及语义无效报告、过大交接、保留上一份有效交接的普通子 agent 失败,以及取消后达到子 agent 静止状态。一项已发布的无密钥无头快照还会启动真实的 `examples/headless-agent` 组合、调用 `ralph`、固定父级流式转录,并检查持久化日志中存在两个不同且无种子的子会话,且第一轮交接只出现在第二轮。工具测试固定通用调用/结果展示,而 ACP 重放请求头快照固定发布的 schema 与提示指导转录表面。 + +## 考虑过的替代方案 + +- **把 Ralph 放进同会话目标驱动器** — 拒绝,因为目标轮次有意保留同一段对话,而 Ralph 的定义性属性是每轮使用全新上下文;合并两者会让目标生命周期与子 agent 编排无法分离。 +- **在通用工作流工具上暴露 `fresh` 或循环标志** — 拒绝,因为模型编写的脚本表面应保持通用且与 provider 无关;Ralph 的固定报告协议和停止策略值得拥有一个可评审消费者。 +- **为了方便重放而使用 `subagent_fork`** — 拒绝,因为继承的已完成回合是隐式、不断增长的交接状态,并违反全新上下文契约。工作区加一份结构化报告即可重放,无需插入人为取消记录。 +- **让工具直接调用 subagent 接缝** — 拒绝,因为现有工作流引擎已经拥有前台编排、结构化子 agent、取消传播、工作线程终止、事件和静止处置。复用它可以展示插件组合,而不是构建第二个循环运行时。 +- **静默截断大型报告** — 拒绝,因为截断可能删除状态证据或下一步,却仍看似权威交接。生产者必须在配置边界内发出有效报告。 + +## 后果 + +- 全新 agent 迭代成为一项一等模型工具,并完全以现有接缝之上的可移除插件实现。 +- 目标轮次与 Ralph 轮次保持不同概念:前者是一次同会话续行回合,后者是前台工作流中的一个全新子 agent。 +- 工作区成为权威跨轮记忆,因此工作者必须检查和验证工作区,而不能信任叙事性交接。 +- 宽裕的轮次上限允许大量自治工作,而部署配置仍会限制子 agent 数量,并且每次交接始终受大小约束。 +- provider 路由与可降低的每次运行子 agent 上限成为显式的工作流启动关注点,但不扩展脚本或普通工作流工具表面。 + +## 已知限制与推迟工作 + +- 完成与阻塞状态由工作者自行声明。独立 evaluator、evaluator 驱动的反馈轮次、完成证书或对抗式 verifier 被有意推迟。 +- 运行位于前台且只存在于进程内。后台收集、持久化/恢复、调度和重启恢复均不存在。 +- 轮次数是唯一聚合预算。token、货币、耗时和 provider 用量预算仍属于未来的独立策略。 +- 每轮创建一个子 agent。轮内扇出、evaluator/工作者角色分离、动态 provider 或模型选择,以及跨运行日志均被推迟。 +- 普通子 agent 失败会结束运行且不重试,同时保留失败轮次与上一份成功交接。致命工作流基础设施错误可能在固定脚本返回该状态前结束;增加重试或更丰富的失败传输需要独立的策略与接缝设计。 +- 提示指导模型不要递归调用 Ralph;结构化的子 agent 工具限制需要另行设计工作流子策略表面。 diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml new file mode 100644 index 0000000000..5379f25772 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.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-human-goal-command.md: a272206a3bfad50a01ce871c56c7e7bcf924684e +2026-07-19-human-goal-command.zh.md: 370c9bc24510320c70e3d789926c492e543968b1 diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md new file mode 100644 index 0000000000..a272206a3b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md @@ -0,0 +1,72 @@ +# Agent Note: Human `/goal` command + +Status: implemented + +English | [中文](2026-07-19-human-goal-command.zh.md) + +## Problem + +The same-session goal domain and model tools provide the state machine and semantic natural-language path, but they are not a sufficient human UX. A user needs to inspect the exact current phase and round budget without asking the model, explicitly pause or clear work without spending a model turn, and rearm a restored active goal after the required post-resume human decision. Implementing those actions independently in TUI and ACP would duplicate parsing, let the surfaces drift, and risk routing an unknown or unavailable command into the model. + +The command must also respect the goal design's two kinds of state. Durable phase, objective, revisions, and rounds come from the session log; process-local activation decides whether an active goal may continue automatically. Showing only “active” after a resume would be misleading when the restored goal is intentionally disarmed and waiting for human authorization. + +## Decision + +`@deepseek-ai/dsh-command-goal` in `packages/goal/command-goal/` is a command producer over `ctx.commands` and `ctx.goals`. It registers one global `goal` definition, so every command adapter in the composition discovers the same command; an incompatible app omits this producer rather than masking its registration at an adapter. The handler receives the exact target agent from command dispatch, reads or mutates that agent's goal through the domain service, and returns direct plain-text UI output. It does not import either adapter or the concrete agent loop. + +The command follows the compact Codex shape in the [public OpenAI Codex TUI dispatcher at commit `678157a`](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805): bare status, a free-form objective, and `clear`, `edit`, `pause`, or `resume` controls. The commit permalink makes the researched grammar durable even as Codex evolves. This repository keeps its own event-sourced state, round-count policy, and post-resume activation rule rather than copying Codex's SQLite, token budget, or automatic-resume behavior. + +### Grammar and lifecycle verbs + +`/goal` reports the objective, human-readable durable phase, `roundsStarted/maxGoalRounds`, process-local `armed` or `disarmed` activation, and commands meaningful from that state. With no current goal it reports that fact plus complete usage. Reading status adds no session event. + +`/goal ` creates an active armed goal. A completed goal may be replaced, which creates a fresh goal identity through the existing domain rule. Any unfinished goal makes the command fail directly with instructions to use inline edit or explicit clear. The generic command service deliberately has no modal confirmation API, so silently clearing and creating two durable records would manufacture destructive consent and expose a non-atomic failure window. + +`/goal edit ` edits the current non-complete goal without changing phase or activation. On a completed goal it creates a fresh active goal because the domain does not permit completed state to resume and a new completion objective is a new goal identity. Bare `edit` is an error rather than an editor launch because ACP's shared unstructured command contract has no portable modal editor. + +`/goal pause`, `/goal resume`, and `/goal clear` call the matching compare-and-set domain verbs against the current view. Resume covers both stopped durable phases and an active-but-disarmed goal after session resume, fork, or driver replacement. Domain rules still reject exhausted round caps, redundant active/armed resume, invalid phase transitions, and stale identity. Clear removes the current pointer while the session log retains the revisioned tombstone and earlier snapshots. + +Control words are ASCII-case-insensitive after outer whitespace trimming. They are controls only when they occupy the full suffix; any other non-empty text is an objective. This matches the predictable free-form command rule: `/goal pause after verification` is a goal objective, not a partially parsed pause command. + +### Output and failure boundary + +Status output omits branded ids and compare-and-set revisions because those are model/plugin coordination details rather than human controls. It includes activation because that fact changes whether work will continue, and a blocked goal includes its durable policy code and human-readable explanation. Command hints are derived from the exact state: an armed active goal offers pause, a disarmed active or paused/blocked goal offers resume, and a completed goal offers replacement or clear. + +Expected `GoalError` failures become one stable, branded-id-free `CommandResult.error`, so domain diagnostics do not leak compare-and-set internals into the human surface and invalid operations never enter model history. The current status supplies the actionable state-specific recovery. Other exceptions remain adapter-visible command failures; treating programmer faults as ordinary domain errors would hide defects. The command handler performs only synchronous domain mutations, so request cancellation is decided by the command registry before the mutation begins and there is no escaped asynchronous side effect to unwind. + +Generic slash input, status text, and errors are not persisted. Successful goal mutations use the existing `Agent.inject()` path, producing the raw model-visible goal snapshot or clear tombstone that persistence already owns. The command therefore changes no session format and introduces no second audit record that could disagree with the domain event. + +### App composition + +`agent-spine-demo` accepts an optional `goals` composition object containing the goal-domain and model-tool owner configs. Omission or `false` leaves the stack unmounted. This explicit opt-in is important for headless one-shot callers: their result API settles one correlated physical turn and must not silently become a long-running logical goal operation. + +The interactive app bundles make the opposite product choice. ACP and TUI default `goals` to the owner defaults and mount the goal domain, model tools, same-session driver, command registry, and this producer. Both apps accept `goals: false` as one coherent stack opt-out. The Python SDK runtime closure ships this producer alongside ACP, commands, and the goal stack so an external `cordis.yml` can compose the same command. + +## Testing + +The producer suite uses the real command registry, goal service, agent registry, and session log. It covers Loader-safe exports, registry discovery, disposal, empty status, objective parsing, unfinished replacement refusal, inline edit, completed replacement, all missing-state controls, pause/resume/clear, every durable phase, blocked code/explanation presentation, armed/disarmed presentation, sanitized domain errors, unexpected failures, and persisted mutation records. App composition tests cover explicit spine opt-in, TUI/ACP defaults, coherent opt-out, forwarded domain/tool config, command discovery, the packaged-runtime closure, and the expanded model-tool assembly. A keyless snapshot boots the shipped ACP application, observes its advertised `/goal` metadata, invokes `/goal` directly, and pins the no-model-turn result; the surrounding ACP snapshots also pin the goal tool schemas in that composition. + +## Alternatives considered + +- **Let the model handle `/goal` as ordinary text** — rejected because status and direct lifecycle actions would cost a model turn, could be reinterpreted, and would not provide deterministic ACP discovery. +- **Implement separate TUI and ACP handlers** — rejected because grammar, error behavior, and goal-state formatting would drift and optional deployments could not add or remove the capability as one effect. +- **Add modal editing and replacement confirmation to `ctx.commands`** — rejected because the existing cross-surface contract is unstructured input plus direct output; a general interaction protocol needs more than this one producer. +- **Silently replace an unfinished goal** — rejected because it combines clear and create without atomicity or explicit destructive intent. +- **Expose goal id and revision in human status** — rejected because human actions always target the exact current view inside one synchronous handler; those fields add implementation noise without preventing another race. +- **Enable goals unconditionally in the UI-less spine** — rejected because one-shot SDK/CLI settlement is a physical-turn API, not a goal-operation API. + +## Consequences + +- TUI and ACP expose one Codex-shaped `/goal` command supplied by a removable plugin. +- Human status distinguishes durable phase from live activation and reports the exact goal-round cap. +- Direct pause, resume, clear, creation, and edit consume no model turn while their accepted mutations remain reconstructable from the session log. +- Restored sessions wait for a human decision; `/goal resume` is the literal command path, while an ordinary prompt in any language may authorize the model tool path. +- Headless compositions retain one-turn behavior unless they explicitly opt into goals and define their own long-running settlement contract. + +## Known limitations and deferred work + +- The portable command contract has no modal editor or confirmation interaction; inline edit and explicit clear are intentional until a general cross-surface interaction primitive exists. +- `/goal` does not accept a per-command round cap. Deployment config owns the default, and the authorized model tool can edit a cap after direct human instruction. +- TUI and ACP render portable plain text rather than a continuously updated goal status widget. Reconnectable command output and adapter-specific status indicators are deferred. +- The headless CLI and JSON-RPC front doors do not consume the command registry. +- The command observes and mutates state but does not certify completion or blockers. Evaluator-backed certification remains deferred to a separate policy layer with an explicit authority and isolation contract. diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md new file mode 100644 index 0000000000..370c9bc245 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md @@ -0,0 +1,72 @@ +# Agent Note: 面向人类的 `/goal` 命令 + +Status: implemented + +[English](2026-07-19-human-goal-command.md) | 中文 + +## 问题 + +同会话目标领域和模型工具提供了状态机与自然语言语义路径,但尚不足以构成面向人类的 UX。用户需要在不询问模型的情况下检查准确的当前阶段与回合预算,在不消耗模型轮次的情况下明确暂停或清除工作,并在会话恢复后经过必要的人类决策重新激活已恢复的活跃目标。若在 TUI 与 ACP 中分别实现这些操作,就会重复解析逻辑、导致两个表面发生偏差,还可能把未知或不可用的命令交给模型处理。 + +该命令还必须遵守目标设计中的两类状态。持久阶段、目标描述、修订号与回合来自会话日志;进程本地激活态决定活跃目标能否自动继续。恢复后若只显示“活跃”,就会掩盖目标已被有意设为未激活、正在等待人类授权这一事实。 + +## 决策 + +位于 `packages/goal/command-goal/` 的 `@deepseek-ai/dsh-command-goal` 是构建在 `ctx.commands` 与 `ctx.goals` 之上的命令生产方。它注册一个全局 `goal` 定义,因此组合中的每个命令适配器都会发现同一个命令;不兼容的应用应省略该生产方,而不是在适配器处屏蔽其注册。处理器从命令分发接收准确的目标 agent(智能体),通过领域服务读取或改变该 agent 的目标,并返回直接的纯文本 UI 输出。它不导入任何适配器或具体 agent loop(智能体循环)。 + +该命令遵循 [OpenAI Codex 公共仓库 `678157a` 提交中的 TUI 分发实现](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805)所呈现的紧凑形态:无参数状态查询、自由形式目标描述,以及 `clear`、`edit`、`pause` 或 `resume` 控制。固定到提交的链接使调研所得语法在 Codex 后续演进时仍可核验。本仓库保留自身的事件溯源状态、回合计数策略与恢复后激活规则,而不复制 Codex 的 SQLite、token 预算或自动恢复行为。 + +### 语法与生命周期动词 + +`/goal` 报告目标描述、面向人类的持久阶段、`roundsStarted/maxGoalRounds`、进程本地 `armed` 或 `disarmed` 激活态,以及当前状态下有意义的命令。没有当前目标时,它会报告该事实与完整用法。读取状态不会添加会话事件。 + +`/goal ` 创建活跃且已激活的目标。已完成目标可以被替换,此时通过现有领域规则创建新的目标身份。任何未完成目标都会让命令直接失败,并提示用户使用行内编辑或明确清除。通用命令服务有意不提供模态确认 API;若静默执行清除再创建两条持久记录,就等于凭空制造破坏性同意,并暴露一个非原子的失败窗口。 + +`/goal edit ` 编辑当前未完成目标,但不改变其阶段或激活态。若目标已经完成,则创建一个新的活跃目标,因为领域不允许恢复已完成状态,而新的完成条件应拥有新的目标身份。单独使用 `edit` 会返回错误而不是启动编辑器,因为 ACP 共享的非结构化命令契约没有可移植的模态编辑器。 + +`/goal pause`、`/goal resume` 与 `/goal clear` 使用当前视图调用相应的比较并交换领域动词。恢复既适用于停止的持久阶段,也适用于会话恢复、fork 或驱动器替换后处于活跃但未激活状态的目标。领域规则仍会拒绝已耗尽的回合上限、对已活跃且已激活目标的重复恢复、非法阶段转换与陈旧身份。清除会移除当前指针,而会话日志保留带修订号的墓碑和此前快照。 + +控制词会在去除两端空白后按 ASCII 大小写不敏感方式匹配。只有占据完整后缀时才被视为控制;其余任何非空文本都是目标描述。这保持了可预测的自由形式命令规则:`/goal pause after verification` 是目标描述,而不是被部分解析的暂停命令。 + +### 输出与失败边界 + +状态输出省略品牌化 id 与比较并交换修订号,因为它们属于模型/插件协调细节,而不是人类控制项。输出包含激活态,因为该事实会改变工作是否继续;被阻塞的目标还会包含其持久策略代码和面向人类的说明。命令提示从准确状态派生:已激活的活跃目标提供暂停,未激活的活跃目标或已暂停/被阻塞目标提供恢复,已完成目标则提供替换或清除。 + +预期的 `GoalError` 失败会变为一个稳定且不含品牌化 id 的 `CommandResult.error`,使领域诊断不会向人类表面泄露比较并交换内部细节,非法操作也绝不会进入模型历史。当前状态负责提供针对具体状态且可执行的恢复路径。其他异常仍是适配器可见的命令失败;若把程序缺陷当成普通领域错误,就会隐藏问题。命令处理器只执行同步领域变更,因此请求取消会在变更开始前由命令注册表决定,不存在需要回滚的外逸异步副作用。 + +通用斜杠输入、状态文本与错误不会持久化。成功的目标变更使用现有 `Agent.inject()` 路径,产出持久化本就拥有的原始模型可见目标快照或清除墓碑。因此该命令不会改变会话格式,也不会引入可能与领域事件不一致的第二份审计记录。 + +### 应用组合 + +`agent-spine-demo` 接受可选的 `goals` 组合对象,其中包含目标领域与模型工具的所有者配置。省略或设为 `false` 时不会挂载该栈。对无头单次调用方而言,明确选择加入非常重要:它们的结果 API 会在一个相关物理轮次后结束,不能静默变成长时间运行的逻辑目标操作。 + +交互式应用包作出相反的产品选择。ACP 与 TUI 默认让 `goals` 使用所有者默认值,并挂载目标领域、模型工具、同会话驱动器、命令注册表与本生产方。两个应用都接受 `goals: false` 作为一致的整体退出选项。Python SDK 运行时闭包把本生产方与 ACP、命令及目标栈一并交付,使外部 `cordis.yml` 能组合相同命令。 + +## 测试 + +生产方测试套件使用真实命令注册表、目标服务、agent 注册表与会话日志。它覆盖 Loader 安全导出、注册表发现、资源释放、空状态、目标描述解析、拒绝未完成目标替换、行内编辑、已完成目标替换、所有缺失状态控制、暂停/恢复/清除、每个持久阶段、阻塞代码/说明展示、已激活/未激活展示、经净化的领域错误、意外失败与持久变更记录。应用组合测试覆盖显式主干选择加入、TUI/ACP 默认值、一致退出、转发的领域/工具配置、命令发现、打包运行时闭包与扩展后的模型工具组装。一个无密钥快照会启动交付的 ACP 应用,观察其公布的 `/goal` 元数据,直接调用 `/goal`,并固定不经过模型轮次的结果;周边 ACP 快照还会固定该组合中的目标工具 schema。 + +## 考虑过的替代方案 + +- **让模型把 `/goal` 当作普通文本处理**——不予采纳,因为状态与直接生命周期操作会消耗模型轮次、可能被重新解释,也无法提供确定性的 ACP 发现。 +- **分别实现 TUI 和 ACP 处理器**——不予采纳,因为语法、错误行为与目标状态格式会发生偏差,可选部署也无法把该功能作为一个 effect 统一增删。 +- **为 `ctx.commands` 添加模态编辑与替换确认**——不予采纳,因为现有跨表面契约是非结构化输入加直接输出;通用交互协议所需的设计远超这一个生产方。 +- **静默替换未完成目标**——不予采纳,因为这会在没有原子性或明确破坏性意图的情况下组合清除与创建。 +- **在人类状态中暴露目标 id 与修订号**——不予采纳,因为人类操作始终在一个同步处理器内针对准确当前视图;这些字段只会增加实现噪声,无法消除其他竞争。 +- **在无 UI 主干中无条件启用目标**——不予采纳,因为单次 SDK/CLI 的结束契约是物理轮次 API,而不是目标操作 API。 + +## 后果 + +- TUI 与 ACP 暴露由可移除插件提供的同一个 Codex 形态 `/goal` 命令。 +- 人类状态会区分持久阶段与实时激活态,并报告准确的目标回合上限。 +- 直接暂停、恢复、清除、创建与编辑不消耗模型轮次,而其已接受变更仍可从会话日志重建。 +- 恢复后的会话等待人类决策;`/goal resume` 是字面命令路径,任何语言的普通提示词则可以授权模型工具路径。 +- 无头组合保持单轮行为,除非明确选择加入目标并定义自己的长时间运行结束契约。 + +## 已知限制与延期工作 + +- 可移植命令契约没有模态编辑器或确认交互;在出现通用跨表面交互原语之前,行内编辑与明确清除是有意选择。 +- `/goal` 不接受逐命令回合上限。部署配置拥有默认值;得到直接人类指示后,已授权模型工具可以编辑上限。 +- TUI 与 ACP 渲染可移植纯文本,而不是持续更新的目标状态组件。可重连命令输出和适配器专用状态指示器予以延期。 +- 无头 CLI 与 JSON-RPC 前端不消费命令注册表。 +- 该命令观察并改变状态,但不认证完成或阻塞。基于评估器的认证延期到具有明确权限与隔离契约的独立策略层。 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 new file mode 100644 index 0000000000..e53c591aa5 --- /dev/null +++ b/.agents/notes/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: 7cc3907d708115207e166455ea988120a03d768b +2026-07-19-model-facing-goal-tools.zh.md: 1a381160354d6a2a24f957f41bc9e375c1ab01ca 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 new file mode 100644 index 0000000000..7cc3907d70 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -0,0 +1,66 @@ +# Agent Note: 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, 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; 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. + +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. 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. + +### 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 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, 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 + +- **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. +- 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 owned by the separate [`dsh-command-goal`](../../../../packages/goal/command-goal/README.md) plugin. +- 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 new file mode 100644 index 0000000000..1a38116035 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -0,0 +1,66 @@ +# Agent Note: 面向模型的同会话目标工具 + +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?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。 + +提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。 + +三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。 + +自主目标回合成功报告完成或阻塞后,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免再发起一次不必要的模型请求。直接人类发起的变更不会贡献终止决策:智能体可以确认该变更,并且并发的人类 steering(转向)仍可参与普通的继续执行折叠。 + +### 执行权限 + +每次调用都要求存在 `exec.agent`,且它必须是 `AgentRegistry` 中完全相同的运行中对象、当前继承的驱动发起者,并处于开放轮次内。这些检查在执行时进行,不能通过提示词注入或手写工具参数绕过。 + +创建、编辑、暂停与恢复还要求运行时根智能体的当前轮次已经接纳一条用户消息或用户 steering(转向)事件。根所有权来自实时智能体图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子智能体则仍然是子智能体,不能改变这些状态。用户来源是宿主的证明:`Agent.send()` 和 `steer()` 会把省略的来源默认为 `{ kind: 'user' }`,因此非人类生产者必须标注自己的内容。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 + +完成与阻塞既接受直接人类权限,也接受准确的当前目标回合。目标回合权限要求存在一条来源为目标的 `user/message`,其中目标 id、修订号和回合都与折叠后的当前目标相等。它只授予这两种终止报告权限。直接人类权限可以立即停止目标。 + +### 阻塞阈值 + +`blockedAfterConsecutiveRounds` 是经过校验的正安全整数配置,默认值为 `3`。自主目标回合调用 `blocked` 时,插件会机械地要求至少已经接纳该数量的回合并提供非空说明;配置值也会出现在模型指导中。运行时无法判断这些回合是否遇到了语义上相同的阻塞条件,因此语义等价性仍由模型判断。该计数特意与目标的宽裕继续执行上限分离。 + +## 测试 + +单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal` 和 `get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 + +## 考虑过的替代方案 + +- **依赖提示词指令实施权限**——不予采纳,因为文本可以指导模型判断,却不能认证实时调用者、轮次或来源事件。 +- **把每个目标服务动词分别暴露为工具**——不予采纳,因为紧凑的读取/创建/更新表面可以降低模式成本,并保持统一的比较并交换行为。 +- **要求精确命令短语**——不予采纳,因为自然语言意图(包括英语以外的语言)应由模型解释;执行权限取决于来源,而不是拼写。 +- **根据持久的根或派生元数据授权**——不予采纳,因为成为独立恢复顶层会话的派生应接受新的人类权限,而当前仍受所有权约束的子智能体则不应接受。 +- **允许自主回合编辑或恢复目标**——不予采纳,因为继续执行权限比重新定义或重启人类目标的权限更窄。 +- **把阻塞阈值当作评估器**——不予采纳,因为事件计数无法证明障碍在语义上未改变或确实不可继续。 + +## 后果 + +- 模型获得稳定而紧凑的生命周期表面,无需直接访问目标服务。 +- 改变状态的调用同时受到实时运行时来源与持久比较并交换引用的约束。 +- 人类可以通过普通自然语言请求创建和重新激活目标,而恢复后的会话在收到此类输入前保持静止。 +- 目标回合可以完成或报告重复阻塞,但不能自行扩大任务权限。 +- 部署策略选择阻塞下限;同一个解析后的值同时控制执行与提示词指导。 + +## 已知限制与延期工作 + +- 是否属于重大目标、是否要求继续、目标是否完成以及阻塞条件是否相同,仍由模型进行语义分类。独立评估器或完成证书予以延期。 +- 这些工具会改变目标状态,但不调度目标回合、不分类异常驱动停止,也不取消活跃轮次;这些行为由同会话驱动器负责。 +- 除非另行挂载的继续执行驱动器接纳了目标来源的用户轮次,否则目标回合权限路径处于休眠状态;本工具包本身不会制造这种权限。 +- 面向人类的斜杠命令发现与渲染由独立的 [`dsh-command-goal`](../../../../packages/goal/command-goal/README.md) 插件负责。 +- 若部署没有同时设定两个注册项的作用域,某个作用域可能隐藏工具注册,却保留独立注册的提示词段。 diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml index 5049947f18..2859ffb99b 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-persisted-same-session-goal-domain.md: b0149016ab1b2a21c6d21798bf8b2117472a0f6c -2026-07-19-persisted-same-session-goal-domain.zh.md: 33f44136da3f0045d9f4baad5154797a6e746bcc +2026-07-19-persisted-same-session-goal-domain.md: 00600b2c49646ebd3b692154ef945eb79a33b032 +2026-07-19-persisted-same-session-goal-domain.zh.md: 6a554438d0d70a5b4ccbf7b6ee77853af9c9ce69 diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md index b0149016ab..00600b2c49 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md @@ -28,7 +28,7 @@ When `Agent.inject()` defers a mutation inside an active tool batch, the service At most one goal is current. Create requires no current non-complete goal and always generates a revision-one id not used earlier in the session; a completed goal may be replaced. Every other mutation carries the expected `GoalRef`, and stale ids or revisions reject. Resume accepts a paused or blocked phase, or a disarmed active goal, only when the round cap has remaining capacity. The domain validates blocker reason shape but deliberately leaves reason codes and the decision to block to policy consumers. -A cache built from any seed starts disarmed, and every `agent/session-start` edge disarms it again. Resume and fork therefore preserve the durable objective and history but never initiate work on their own. A later human prompt can be interpreted by the model, whose policy surface may explicitly call resume and arm the goal. +A cache built from any seed starts disarmed, and every `agent/session-start` edge disarms it again. `GoalService.disarm(agent)` also lets a lifecycle owner remove process-local authority without a session event, revision change, or `goal/changed` notification. Resume, fork, and continuation-driver replacement therefore preserve the durable objective and history but never initiate work on their own. A later human prompt can be interpreted by the model, whose policy surface may explicitly call resume and arm the goal. ### Service boundary @@ -36,7 +36,7 @@ The service accepts only the exact live `Agent` object registered under its id. ## Testing -Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, blocker reason validation and retention, cap enforcement on resume, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start disarming and active-goal rearming, FIFO deferred mutation reconciliation, reentrant append observation, rejected-injection rollback, stable corrupt-event replay, service/listener disposal, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. +Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, blocker reason validation and retention, cap enforcement on resume, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start and lifecycle-owner disarming, active-goal rearming, FIFO deferred mutation reconciliation, reentrant append observation, rejected-injection rollback, stable corrupt-event replay, service/listener disposal, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md index 33f44136da..6a554438d0 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md @@ -28,7 +28,7 @@ Status: implemented 最多只有一个当前目标。创建要求不存在未完成的当前目标,并始终生成该会话此前未使用过、修订号为一的 id;已完成目标可以被替换。其他每次变更都携带预期的 `GoalRef`,陈旧的 id 或修订号会被拒绝。仅当回合上限仍有余量时,暂停或阻塞阶段以及已解除激活的活跃目标才能恢复。领域层校验阻塞原因的形状,但会把原因代码和是否阻塞的决策留给策略消费者。 -从任何种子构建的缓存都以未激活状态开始,每次 `agent/session-start` 边沿也会再次解除激活。因此,恢复和 fork 会保留持久目标与历史,但绝不会自行启动工作。后续人类提示词可由模型解释,其策略表面可以显式调用恢复操作并激活目标。 +从任何种子构建的缓存都以未激活状态开始,每次 `agent/session-start` 边沿也会再次解除激活。`GoalService.disarm(agent)` 还允许生命周期所有者移除进程内权限,而不写入会话事件、不改变修订号,也不发出 `goal/changed` 通知。因此,恢复、fork 和继续执行驱动器替换都会保留持久目标与历史,但绝不会自行启动工作。后续人类提示词可由模型解释,其策略表面可以显式调用恢复操作并激活目标。 ### 服务边界 @@ -36,7 +36,7 @@ Status: implemented ## 测试 -单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、阻塞原因校验与保留、恢复时的上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动时解除激活与活跃目标重新激活、FIFO 延迟变更协调、重入追加观察、注入拒绝回滚、损坏事件的稳定回放、服务与监听器销毁、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 +单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、阻塞原因校验与保留、恢复时的上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动与生命周期所有者解除激活、活跃目标重新激活、FIFO 延迟变更协调、重入追加观察、注入拒绝回滚、损坏事件的稳定回放、服务与监听器销毁、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml new file mode 100644 index 0000000000..0addad9e97 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.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-plugin-command-registration.md: a207c6257bd4e9e4013f1abb661dd967ed2a52dc +2026-07-19-plugin-command-registration.zh.md: e21d187ded0ee0f4daa370655994eb306fb1c5fd diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md new file mode 100644 index 0000000000..a207c6257b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md @@ -0,0 +1,81 @@ +# Agent Note: Plugin-owned human command registration + +Status: implemented + +English | [中文](2026-07-19-plugin-command-registration.zh.md) + +## Problem + +The TUI owns seven slash commands, while ACP defines a standard command catalog and invocation shape. Keeping command names, help text, autocomplete, dispatch, and cancellation inside each adapter makes every new command an adapter edit, prevents optional plugins from contributing commands, and lets the two front doors drift. Treating slash input as an ordinary model prompt is also unsafe: a user-visible direct action can unexpectedly consume tokens or let the model reinterpret an unknown command. + +A shared mechanism must remain a UI concern rather than a model tool or agent-loop branch. It also needs exact per-agent visibility, HMR-safe removal, per-session ACP discovery, direct result rendering, and request-scoped cancellation without adding command text or output to model history. + +## Decision + +`@deepseek-ai/dsh-commands` in `packages/ui/commands/` is the product command registry. The terminal and ACP app bundles mount it beside their consuming front door, and the SDK project helper emits the same service when scaffolding ACP directly; the executor-less, UI-less agent spine remains independent. TUI and ACP inject the service, while command producers depend only on the registry and any domain they operate. + +### Registry contract + +A `CommandDefinition` contains a lowercase name without `/`, a non-empty description, an optional unstructured-input hint, and an abortable handler. Registration validates and detaches the metadata, freezes the effective definition, and returns the exact Cordis effect disposer. Duplicate names fail within one layer. Every adapter consuming the registry sees every effective definition; a command plugin that cannot operate in a deployment omits its registration there instead of encoding adapter identities in the shared domain. + +`list(agent)` returns immutable name-sorted descriptors after scoped shadowing. `find(agent, name)` resolves the effective definition. `execute(agent, line, signal)` parses and runs a known definition, returning a detached `success` or `error` result; invalid syntax and unknown names return `undefined` so the adapter owns its direct error text. + +`parseCommand(line)` requires `/` at byte zero, a lowercase ASCII name containing letters, digits, `_`, or `-`, then whitespace or end-of-input. It preserves the complete adapter-delivered suffix as `rawInput`, including separator whitespace. Command-specific plugins own every further grammar decision. + +### Scope and lifecycle + +An unscoped registration is global. A command-injected plugin mounted beneath an agent context inherits that agent's scope key and lifetime, so its definition shadows a same-named global only for that exact agent. The child declares its own `commands` injection because `agent.ctx` intentionally inherits the core agent-loop dependency surface; adding a UI service to the loop merely to enable scoped registration would invert the dependency graph. + +Registration and removal emit the unfiltered, non-vetoing `commands/change` registry notification. Adapters recompute each live agent's effective view rather than trying to infer which sessions a change affects. The registry contains and logs each observer failure independently, so a broken UI refresh cannot roll back another plugin's mutation or starve a later observer. Cordis ownership removes definitions when their producer, UI instance, or agent scope unloads, so HMR cannot leave stale discovery entries or handlers. + +### Direct dispatch and cancellation + +Commands run in a human-only command plane. Their input does not become `user/message`, their output does not become a session event, and neither is sent to the model. A handler receives the exact target agent, raw input, and request-owned `AbortSignal`. The registry stops awaiting an uncooperative handler when the signal aborts; the handler remains responsible for stopping external side effects already started. + +Expected handler failures return `CommandResult.error`. Thrown or malformed results remain adapter-visible command failures, not model messages. This boundary deliberately separates UI output from durable domain mutation: a goal command may change `ctx.goals`, for example, but the goal service owns that persisted state. + +### TUI mapping + +The TUI registers `help`, `clear`, `cancel`, `reasoning`, `tools`, `redraw`, and `exit` as agent-scoped command definitions instead of switching on strings. Its autocomplete and help view read the live catalog, so plugin commands appear and disappear with their effects. Any submitted line beginning with `/` stays in the command plane; unknown input produces a terminal warning rather than falling through to `Agent.send()` or `Agent.steer()`. + +Each submitted command owns an `AbortController`. TUI disposal aborts outstanding dispatches, removes the local definitions, and waits for the command-producing fiber before completing teardown. + +### ACP mapping + +The bridge follows the current [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). `session/new` and `session/load` emit the exact agent's full `available_commands_update` snapshot; a new session's RPC response introduces its server-generated id before the snapshot is enqueued. Every registry change emits a replacement snapshot for each live session. Names, descriptions, and optional unstructured-input hints map directly to `AvailableCommand`. + +ACP permits a command prompt to contain additional supported content blocks. The bridge applies its ordinary lossless `text` and `resource_link` flattening, then enters the command plane when the result starts with `/`. Unsupported prompt blocks are rejected by the existing capability boundary. Known commands execute directly; unknown or malformed slash input returns a direct error and never reaches the model. Successful text, expected errors, and thrown-failure diagnostics stream as live `agent_message_chunk` output and settle `end_turn`. + +One model prompt or direct command may be in flight per ACP session, independently across sessions. `session/cancel` aborts the direct command when one owns the request; it calls `Agent.cancel()` only for an agent prompt, so cancelling a command cannot destroy unrelated queued or injected agent work. Connection teardown aborts commands and then disposes the owned agents. + +## Testing + +The registry suite covers syntax boundaries, immutable normalization, runtime metadata validation, deterministic sorting, global and scoped shadowing, duplicate rejection, exact disposal, contained change-notification failures, direct invocation, expected and malformed results, synchronous and asynchronous failure, and every abort timing edge at per-file 100% statement, branch, function, and line coverage. + +TUI tests exercise all migrated built-ins, live plugin discovery, help/autocomplete refresh, direct results, unknown-command rejection, raw-input delivery, definition removal, startup rollback, and disposal cancellation. ACP tests use the real SDK connection, agent factory, loop, and JSONL persistence to verify create/load snapshots, dynamic updates, scoped multi-session catalogs, supported-block flattening, direct success/error/failure, unknown-command isolation, cancellation, and the absence of model requests or session messages. The SDK helper suite pins direct-ACP composition. Keyless ACP and terminal snapshots pin the new protocol and rendered transcript shapes. + +## Alternatives considered + +- **Keep adapter-local switches** — rejected because optional plugins cannot contribute discovery and behavior without editing every front door. +- **Represent human commands as model tools** — rejected because discovery and direct invocation are human UI behavior; routing through the model adds latency, token cost, and reinterpretation. +- **Put the registry in the core agent spine** — rejected because headless and JSON-RPC agents do not consume it, while the two UI app bundles can compose it explicitly. +- **Make `dsh-agent-loop` inject commands** — rejected because the loop does not execute or discover human commands. Agent-scoped producers declare the UI dependency in a child plugin instead. +- **Attach adapter masks to each definition** — rejected because support is a composition fact, not command-domain state. Every composed adapter exposes a registered command; an incompatible plugin omits registration in that deployment. +- **Send unknown slash input to the model** — rejected because typoed or unavailable direct actions must fail predictably rather than change execution planes. +- **Persist generic command input and output** — rejected because adapter notices are not model-visible state. A handler that changes durable behavior calls the owning domain API, which records its own events. +- **Restrict ACP commands to one text block** — rejected because ACP v1 permits accompanying content; the bridge already has a lossless accepted-block translation. + +## Consequences + +- Command producers are ordinary removable plugins, and TUI/ACP share one validated catalog and dispatch contract. +- Agent-specific definitions retain existing flat scope and shadow semantics without a core-to-UI dependency. +- Unknown slash input and command output are deterministic UI behavior with zero direct model tokens. +- ACP clients receive current per-session snapshots after creation, load, registration, and HMR removal. +- Direct command cancellation is isolated from model-turn cancellation. + +## Known limitations and deferred work + +- Input metadata is ACP's current unstructured text hint. Typed forms, argument schemas, and completion providers remain command-owned or require a later protocol extension. +- Generic command output is live-only and is not reconstructed after TUI restart or ACP reconnect. +- Registry cancellation stops awaiting immediately, but external work stops only when a handler cooperates with its signal. +- The headless CLI and JSON-RPC SDK front doors do not expose the command plane; only TUI and ACP consume it. diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md new file mode 100644 index 0000000000..e21d187ded --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md @@ -0,0 +1,81 @@ +# Agent Note: 插件拥有的人类命令注册 + +Status: implemented + +[English](2026-07-19-plugin-command-registration.md) | 中文 + +## 问题 + +TUI 拥有七个斜杠命令,而 ACP 定义了标准命令目录与调用形态。如果命令名、帮助文本、自动补全、分派和取消都留在各适配器内部,每个新命令都需要修改适配器,可选插件无法贡献命令,两个前端也会逐渐偏离。把斜杠输入当作普通模型提示同样不安全:用户可见的直接操作可能意外消耗 token,或让模型重新解释未知命令。 + +共享机制必须仍是 UI 关注点,而不是模型工具或智能体循环分支。它还需要精确的逐智能体可见性、可安全 HMR 移除、逐会话 ACP 发现、直接结果渲染和请求作用域取消,同时不得把命令文本或输出加入模型历史。 + +## 决策 + +位于 `packages/ui/commands/` 的 `@deepseek-ai/dsh-commands` 是产品命令注册表。终端与 ACP 应用 bundle(组合包)把它挂载在消费该服务的前端旁,SDK 项目 helper(辅助器)在直接搭建 ACP 时也会生成同一服务;无执行器、无 UI 的智能体 spine(主干)保持独立。TUI 与 ACP 注入该服务,命令生产者只依赖注册表及其操作的领域。 + +### 注册表契约 + +`CommandDefinition` 包含不带 `/` 的小写名称、非空描述、可选的非结构化输入提示,以及可取消处理器。注册会校验并分离元数据、冻结有效定义,并返回准确的 Cordis effect disposer(副作用释放器)。同一层中的重复名称会失败。每个消费该注册表的适配器都能看到所有有效定义;若命令插件无法在某种部署中运行,它就不在该部署中注册,而不是把适配器身份编码进共享领域。 + +`list(agent)` 在作用域遮蔽后返回不可变、按名称排序的描述符。`find(agent, name)` 解析有效定义。`execute(agent, line, signal)` 解析并运行已知定义,返回分离后的 `success` 或 `error` 结果;无效语法和未知名称返回 `undefined`,由适配器拥有直接错误文本。 + +`parseCommand(line)` 要求 `/` 位于第零字节,后接由字母、数字、`_` 或 `-` 组成的小写 ASCII 名称,并以空白或输入末尾结束。它把适配器交付的完整后缀保留为 `rawInput`,包括分隔空白。每个命令插件自行拥有后续语法决策。 + +### 作用域与生命周期 + +无作用域注册是全局注册。挂载在智能体上下文之下并注入 `commands` 的插件会继承该智能体的作用域键与生命周期,因此其定义仅为该准确智能体遮蔽同名全局定义。子插件自行声明 `commands` 注入,因为 `agent.ctx` 有意只继承核心智能体循环的依赖界面;仅为了实现作用域注册而让循环依赖 UI 服务会倒置依赖图。 + +注册和移除会发出未过滤、不可否决的 `commands/change` 注册表通知。适配器重新计算每个实时智能体的有效视图,而不尝试推断某次变更影响哪些会话。注册表会分别隔离并记录每个观察者失败,因此损坏的 UI 刷新无法回滚另一插件的变更,也无法阻止后续观察者。Cordis 所有权会在生产者、UI 实例或智能体作用域卸载时移除定义,因此 HMR 不会留下陈旧的发现项或处理器。 + +### 直接分派与取消 + +命令在仅面向人类的命令平面中运行。输入不会成为 `user/message`,输出不会成为会话事件,两者都不会发送给模型。处理器接收准确的目标智能体、原始输入和请求拥有的 `AbortSignal`。信号中止时,注册表不再等待不合作的处理器;处理器仍负责停止已经启动的外部副作用。 + +预期的处理器失败返回 `CommandResult.error`。抛出的异常或格式错误的结果仍是适配器可见的命令失败,而不是模型消息。该边界有意分离 UI 输出与持久领域变更:例如目标命令可以改变 `ctx.goals`,但持久状态由目标服务拥有。 + +### TUI 映射 + +TUI 把 `help`、`clear`、`cancel`、`reasoning`、`tools`、`redraw` 和 `exit` 注册为智能体作用域命令定义,不再对字符串执行 switch。自动补全与帮助视图读取实时目录,因此插件命令会随其副作用出现和消失。任何以 `/` 开头的提交行都留在命令平面;未知输入产生终端警告,不会落入 `Agent.send()` 或 `Agent.steer()`。 + +每个提交的命令拥有一个 `AbortController`。TUI 释放会中止未完成的分派、移除本地定义,并等待命令生产者 fiber(纤程)后再完成清理。 + +### ACP 映射 + +桥接遵循当前的 [ACP v1 斜杠命令契约](https://agentclientprotocol.com/protocol/v1/slash-commands)。`session/new` 与 `session/load` 发出准确智能体的完整 `available_commands_update` 快照;新会话的 RPC 响应会先引入服务端生成的 id,随后快照才会入队。每次注册表变更都会为每个实时会话发出替换快照。名称、描述和可选非结构化输入提示直接映射到 `AvailableCommand`。 + +ACP 允许命令提示携带额外的受支持内容块。桥接应用普通的无损 `text` 与 `resource_link` 扁平化,然后在结果以 `/` 开头时进入命令平面。不支持的提示块由现有能力边界拒绝。已知命令直接执行;未知或格式错误的斜杠输入返回直接错误,绝不会到达模型。成功文本、预期错误和抛出失败的诊断作为实时 `agent_message_chunk` 输出流式发送,并以 `end_turn` 结束请求。 + +每个 ACP 会话同时只能有一个模型提示或直接命令进行中,各会话彼此独立。当直接命令拥有请求时,`session/cancel` 会中止它;只有智能体提示才调用 `Agent.cancel()`,因此取消命令不会销毁无关的排队或注入智能体工作。连接清理会先中止命令,再释放所拥有的智能体。 + +## 测试 + +注册表测试覆盖语法边界、不可变规范化、运行时元数据校验、确定性排序、全局与作用域遮蔽、重复拒绝、准确释放、变更通知失败隔离、直接调用、预期和格式错误结果、同步与异步失败,以及每种中止时序边沿;该源文件达到逐文件 100% 语句、分支、函数和行覆盖率。 + +TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与自动补全刷新、直接结果、未知命令拒绝、原始输入交付、定义移除、启动回滚和释放取消。ACP 测试使用真实 SDK 连接、智能体工厂、循环与 JSONL 持久化,验证创建/加载快照、动态更新、作用域多会话目录、受支持块扁平化、直接成功/错误/失败、未知命令隔离、取消,以及不存在模型请求或会话消息。SDK helper 测试固定直接 ACP 组合。无密钥 ACP 与终端快照固定新的协议和渲染记录形态。 + +## 考虑过的替代方案 + +- **保留适配器本地 switch**——不予采纳,因为可选插件无法贡献发现与行为,除非修改每个前端。 +- **把人类命令表示为模型工具**——不予采纳,因为发现与直接调用属于人类 UI 行为;经由模型路由会增加延迟、token 成本和重新解释。 +- **把注册表放入核心智能体主干**——不予采纳,因为无头和 JSON-RPC 智能体不消费它,而两个 UI 应用组合包可以显式组合它。 +- **让 `dsh-agent-loop` 注入 commands**——不予采纳,因为循环不执行也不发现人类命令。智能体作用域生产者改为在子插件中声明 UI 依赖。 +- **为每个定义附加适配器掩码**——不予采纳,因为支持能力是组合事实,而不是命令领域状态。每个已组合适配器都暴露已注册命令;不兼容插件不会在该部署中注册。 +- **把未知斜杠输入发送给模型**——不予采纳,因为输入错误或不可用的直接操作必须可预测地失败,而不能改变执行平面。 +- **持久化通用命令输入与输出**——不予采纳,因为适配器提示不是模型可见状态。改变持久行为的处理器会调用拥有该状态的领域 API,由后者记录自己的事件。 +- **把 ACP 命令限制为单个文本块**——不予采纳,因为 ACP v1 允许附带内容,而桥接已有无损的已接纳块转换。 + +## 后果 + +- 命令生产者是普通的可移除插件,TUI 与 ACP 共享一个经过校验的目录和分派契约。 +- 智能体特定定义保留现有扁平作用域与遮蔽语义,不引入核心到 UI 的依赖。 +- 未知斜杠输入与命令输出是确定性 UI 行为,直接模型 token 成本为零。 +- ACP 客户端在创建、加载、注册和 HMR 移除后收到当前的逐会话快照。 +- 直接命令取消与模型轮次取消彼此隔离。 + +## 已知限制与延期工作 + +- 输入元数据仅为 ACP 当前的非结构化文本提示。类型化表单、参数模式和补全提供器仍由命令拥有,或需要后续协议扩展。 +- 通用命令输出仅实时存在,TUI 重启或 ACP 重新连接后不会重建。 +- 注册表取消会立即停止等待,但外部工作只有在处理器配合信号时才会停止。 +- 无头 CLI 与 JSON-RPC SDK 前端不暴露命令平面;只有 TUI 和 ACP 消费它。 diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml new file mode 100644 index 0000000000..f28ec1e2b7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.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-same-session-goal-round-driver.md: 34d59456b5a8b54c92aba581da0ff22ea045b626 +2026-07-19-same-session-goal-round-driver.zh.md: dc2afd1ce18a45964bc1db04121211a9958445f3 diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md new file mode 100644 index 0000000000..34d59456b5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md @@ -0,0 +1,100 @@ +# Agent Note: Same-session goal-round driver + +Status: implemented + +English | [中文](2026-07-19-same-session-goal-round-driver.zh.md) + +## Problem + +The goal domain can retain an objective and the model-facing tools can mutate its lifecycle, but neither should decide when another model turn begins. A continuation driver must bridge active goal state to the ordinary agent loop without adding goal-specific branches to `dsh-agent-loop`, inventing a second conversation, or treating every human turn as an autonomous iteration. + +That bridge has concurrency and durability obligations. Human input, cancellation, a goal edit, persistence failure, session restart, plugin unload, and a downstream prompt policy can all race a pending continuation. A naive `goal/changed -> agent.send()` listener can admit obsolete work, run alongside a human prompt, spend beyond the cap, or restart from replay without new authority. + +## Decision + +`@deepseek-ai/dsh-goal-session` in `packages/goal/goal-session/` is a policy plugin over `ctx.goals`, the public `Agent` interface, and durable session events. It imports no concrete agent-loop implementation. For each exact live `Agent`, it owns process-local scheduling state and may reserve at most one automatic round. + +The hierarchy is Goal → Goal Round → Turn → Step. A goal round is the outer continuation policy iteration; it becomes one goal-sourced session turn, and that turn can contain any number of ordinary model/tool steps. Human turns in the same session are not goal rounds and never increment `roundsStarted`. + +The plugin has no configuration. `maxGoalRounds` is resolved and persisted by `dsh-goal`, and the same-condition blocking threshold is resolved and prompted by `dsh-tool-goal`. Repeating those tunables in the driver would create multiple owners for one policy. + +### Reservation and admission + +When an agent is idle, has no competing queued work, and its current goal is `active` plus `armed`, the driver checkpoints pending goal mutations and rechecks every predicate after the await. If `roundsStarted` already equals `maxGoalRounds`, it records `blocked` with code `round-limit`. Otherwise it reserves the exact identity `{ goalId, revision, round: roundsStarted + 1 }` and the complete rendered prompt before calling `Agent.send()` with `GoalMessageSource`. The prompt JSON-quotes the objective so multiline or tag-like text remains an unambiguous data value inside the familiar frame. + +The `agent/prompt-submit` waterfall is the admission fence. A positive goal source is allowed only when it exactly matches the driver's pending identity and content, the live goal still has that id and revision, activation remains armed, and the round is still the next number. The plugin checks once before delegating and again after downstream hooks return. This second check prevents an async hook from editing or pausing the goal while still admitting the old prompt. + +Only the resulting `user/message` is an admitted round and advances the goal fold. A stale reservation becomes a durable `prompt/blocked` plus zero-step rejected turn, but the driver marks it stale and does not charge the round. A downstream policy rejection that is not caused by staleness blocks the goal rather than retrying around policy. + +### Human work and revision races + +`agent/queued` distinguishes the driver's complete accepted record from every other prompt. Ordinary work already queued before a reservation prevents scheduling. Ordinary work queued while an automatic prompt is pending makes that reservation stale, so a mixed batch admits the human prompt but rejects the automatic one. Ordinary work arriving after the goal round was admitted remains queued for its own next turn; continuation is reconsidered only when the agent later becomes idle. + +A goal mutation during a round advances its durable revision. Settlement of the older revision cannot overwrite that mutation. The driver discards the old attempt outcome, reads the new projection, and continues only if the new revision is still active and armed. This makes model-recorded completion, pause, block, and edit authoritative over the physical turn's later close reason. + +### Settlement + +The driver classifies one closed goal-owned turn as follows: + +| Turn result | Action | +|---|---| +| durable `completed` | continue while active/armed and under cap | +| cancellation of a reserved/admitted goal round, or its `aborted` result | pause and disarm | +| `error` with code `RATE_LIMIT` or `QUOTA` | block with code `usage-limited` | +| other `error` | block with code `turn-error` | +| `max-tokens` | block with code `max-tokens` | +| non-stale `rejected` | block with code `prompt-rejected` | +| failed durability checkpoint | disarm without changing durable phase | +| `disposed` or `interrupted` | disarm | +| plugin-added unknown result | block for inspection | + +No abnormal outcome requests an automatic retry. A later human prompt can ask to continue in any language; the model reads the stopped goal and uses the goal tool's resume action, which records a new revision and arms continuation. + +### Durability and cancellation seam + +Every `goal/changed` notification creates a checkpoint obligation. The driver awaits `ctx.sessions.flush(session)` before reserving work, then checks for a newer mutation, agent lifecycle change, or competing prompt. Turn-end flush failure is reported by the existing `agent/error` notification after `turn/end`; the driver finds that exact closed turn even when a concurrent one-shot injection appended a later turn, associates the failure with the exact attempt, and disarms before the next idle decision. + +Broad cancellation previously exposed only its effects after queues were cleared or the request aborted. The public agent vocabulary now includes observe-only `agent/cancel-requested(agent, reason)`. The concrete loop emits it for effective cancellation before either action; fused notification containment means a broken listener cannot veto cancellation. The goal driver uses this edge to clear its reservation before the loop destroys the queued-work evidence. When that reservation is a queued or admitted goal attempt, cancellation durably pauses the goal; when cancellation belongs to unrelated human work with no goal attempt, it only removes process-local activation. If the pause mutation throws, the driver falls back to disarming rather than allowing cancelled automatic work to restart. + +This is a coordination notification, not a second stop API. `Agent.cancel()` remains the only public broad cancellation verb, idle calls remain no-ops, and custom `Agent` implementations that claim the interface must honor the event ordering if consumers depend on it. + +### Process lifecycle + +`GoalService.disarm(agent)` removes only process-local activation. It writes no session event, changes no revision, and emits no goal mutation. The driver calls it while loading over existing agents, on durability uncertainty, and before teardown; a later `resume` is the durable activation edge visible to the model. + +The driver's event listeners and quiescent close are nested in one ordered Cordis effect. Cordis unloads sibling effects concurrently, so separate listener and cleanup registrations could remove the prompt fence while an async disposer was still draining. The composite effect first closes admission, disarms goals, cancels an admitted attempt, and awaits both agent and driver quiescence; only then does it unregister its listeners. + +An inbox acceptance can win the microtask race immediately before plugin unload begins. In that case the turn and even its first request may start and the round remains durably charged; once unload starts, cancellation aborts it, no following round is scheduled, and the goal remains active but disarmed. Pretending that already-observed admission never happened would corrupt replay accounting. + +## Testing + +The unit suite uses the real agent loop and session service with only the model scripted. It covers exact sequential admission and cap enforcement, load/resume inertness, every outcome classification, rate limiting, request errors, max tokens, downstream prompt veto, pre-admission and in-flight cancellation, unrelated-human cancellation, failed-pause fallback, human-input ordering, queued and downstream revision races, forged goal attribution, failed mutation and turn checkpoints including a later one-shot injection, scheduler and custom-agent failures, session-start reset, exact lifecycle retirement, and queued/running plugin teardown. The new driver source has per-file 100% statement, branch, function, and line coverage. + +A keyless ACP snapshot mounts the shipped editor app with the real goal domain, goal tools, goal driver, agent loop, persistence, and replay adapter through `cordis.yml`. One human turn creates and inspects a two-round goal, the first automatic turn stops normally, and ACP cancellation of a deliberately stalled second round records a durable pause. The normalized wire transcript and external JSONL assertions prove one session, round sources `1, 2`, the lifecycle mutation, and exact replay accounting without using `echo-agent` as an application surrogate. + +The core cancellation test proves notification order and containment: observers run only for effective cancellation, can queue replacement work before the inbox clear, cannot veto later observers by throwing, and an idle call emits nothing. + +## Alternatives considered + +- **Add a goal loop inside `dsh-agent-loop`** — rejected because the public queue, prompt, session, cancellation, and status seams are sufficient, and a concrete-loop branch would privilege one policy. +- **Use `agent/turn-continuation` to make every round another step** — rejected because a goal round is an outer policy iteration and must have its own durable user prompt, turn boundary, round count, and failure settlement. +- **Persist a pending reservation** — rejected because a crash cannot prove that queued process memory had reached admission; only the durable `user/message` consumes the round. +- **Retry provider or persistence errors automatically** — rejected because retry policy spends resources and needs explicit authority; stopped phases plus later human resume are simpler and observable. +- **Fork conversation history or spawn a fresh agent for every round** — rejected for this package because the goal is explicitly same-session work. Fresh-agent Ralph execution remains a separate workflow plugin built from subagent and workflow primitives. +- **Reuse every session turn as the round counter** — rejected because human clarification and unrelated work share the session but not the automatic-work budget. + +## Consequences + +- Goal continuation remains a removable plugin and the concrete loop gains only a generic observe-before-cancel notification. +- Replay can reconstruct every admitted round from its exact goal source and prompt; rejected reservations cannot create phantom budget use. +- Human messages and lifecycle mutations win documented races without corrupting the revision or counter. +- Resume and fork remain inert until semantic human intent causes the model to record a resume mutation. +- Conservative failure mapping can require manual continuation after transient failures, but it never hides an automatic retry. + +## Known limitations and deferred work + +- Completion evidence and semantic blocker equivalence remain model judgments. An independent evaluator, completion certificate, or verifier-driven stop policy is deferred to a separate policy plugin. +- This package does not provide Ralph-style fresh-agent attempts, context reset, cross-round evaluator feedback, or workflow-level parallelism; those belong to the separate Ralph workflow tool. +- Cordis unload begins asynchronously. An already accepted inbox item may enter one charged round and start one request before teardown cancellation takes effect; the closing drain prevents every subsequent round. +- `maxGoalRounds` is only an admitted-round limit. Token, currency, wall-clock, and provider-usage budgets require independent policy. +- A custom `Agent` implementation must produce the documented session events, status edges, cancel notification, and quiescence semantics; structural TypeScript compatibility alone cannot verify runtime ordering. diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md new file mode 100644 index 0000000000..dc2afd1ce1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md @@ -0,0 +1,100 @@ +# Agent Note: 同会话目标回合驱动器 + +Status: implemented + +[English](2026-07-19-same-session-goal-round-driver.md) | 中文 + +## 问题 + +目标领域可以保留目标,模型可见工具也可以变更其生命周期,但两者都不应决定下一个模型轮次何时开始。继续执行驱动器必须把活跃目标状态连接到普通 agent(智能体)循环,同时不能向 `dsh-agent-loop` 添加目标专用分支、创建第二段对话,也不能把每个人类轮次都视为自主迭代。 + +这层连接还承担并发与持久性义务。人类输入、取消、目标编辑、持久化失败、会话重启、插件卸载以及下游提示词策略都可能与待处理的继续执行发生竞争。简单的 `goal/changed -> agent.send()` 监听器可能接纳过期工作、与人类提示词同时运行、超出上限消耗资源,或在回放后未经新授权自行重启。 + +## 决策 + +位于 `packages/goal/goal-session/` 的 `@deepseek-ai/dsh-goal-session` 是构建在 `ctx.goals`、公共 `Agent` 接口和持久会话事件之上的策略插件。它不导入具体 agent-loop 实现。对于每个完全相同的实时 `Agent`,它维护进程内调度状态,并且最多保留一个自动回合预留。 + +层次关系为目标(Goal)→ 目标回合(Goal Round)→ 轮次(Turn)→ 步骤(Step)。目标回合是外层继续执行策略的一次迭代;它会成为一个归属于目标的会话轮次,而该轮次可以包含任意数量的普通模型或工具步骤。同一会话中的人类轮次不是目标回合,也绝不会增加 `roundsStarted`。 + +该插件没有配置项。`maxGoalRounds` 由 `dsh-goal` 解析并持久化;“相同阻塞条件”的门槛由 `dsh-tool-goal` 解析并写入提示词。若驱动器重复声明这些可调值,一个策略就会出现多个所有者。 + +### 预留与接纳 + +当 agent 空闲、没有竞争中的排队工作,且当前目标为 `active` 加 `armed` 时,驱动器会先检查点持久化待处理的目标变更,并在等待之后重新校验所有条件。若 `roundsStarted` 已等于 `maxGoalRounds`,它会记录代码为 `round-limit` 的 `blocked`;否则,它会先预留精确身份 `{ goalId, revision, round: roundsStarted + 1 }` 和完整渲染提示词,再以 `GoalMessageSource` 调用 `Agent.send()`。提示词用 JSON 引号编码目标描述,使多行或类似标签的文本在熟悉框架中仍是无歧义的数据值。 + +`agent/prompt-submit` 瀑布是接纳栅栏。正数目标来源只有在完全匹配驱动器待处理的身份和内容、实时目标仍具有相同 id 与修订号、激活态仍为 armed,并且该回合仍是下一个编号时才会获准。插件在委托下游监听器前检查一次,在下游返回后再检查一次。第二次检查防止异步钩子编辑或暂停目标后,旧提示词仍被接纳。 + +只有最终产生的 `user/message` 才是已接纳目标回合,并推进目标折叠。过期预留会生成持久的 `prompt/blocked` 和零步骤 rejected 轮次,但驱动器会把它标记为过期,不消耗回合数。若下游策略拒绝并非由过期导致,目标会进入 blocked,而不会绕过该策略自动重试。 + +### 人类工作与修订竞争 + +`agent/queued` 会区分驱动器自己的完整已接受记录与其他所有提示词。预留之前已经排队的普通工作会阻止调度;自动提示词待处理时进入的普通工作会使该预留过期,因此混合批次只接纳人类提示词而拒绝自动提示词。目标回合已经接纳后到达的普通工作会保留在队列中,成为下一个独立轮次;只有 agent 再次空闲后才重新考虑继续执行。 + +目标在回合内发生变更时会推进持久修订号。旧修订的结算不得覆盖该变更。驱动器会丢弃旧尝试的结果、读取新投影,并且只在新修订仍为 active 与 armed 时继续。因此,模型记录的完成、暂停、阻塞和编辑相对于物理轮次稍后的关闭原因具有最终权威。 + +### 结算 + +驱动器按下表分类一个已经关闭、归属于目标的轮次: + +| 轮次结果 | 动作 | +|---|---| +| 持久的 `completed` | 目标仍 active/armed 且未到上限时继续 | +| 取消已预留/接纳的目标回合,或该回合产生 `aborted` 结果 | 暂停并解除激活 | +| 代码为 `RATE_LIMIT` 或 `QUOTA` 的 `error` | 以 `usage-limited` 代码阻塞 | +| 其他 `error` | 以 `turn-error` 代码阻塞 | +| `max-tokens` | 以 `max-tokens` 代码阻塞 | +| 非过期的 `rejected` | 以 `prompt-rejected` 代码阻塞 | +| 持久检查点失败 | 解除激活,但不改变持久阶段 | +| `disposed` 或 `interrupted` | 解除激活 | +| 插件新增的未知结果 | 阻塞并等待检查 | + +异常结果都不会请求自动重试。之后的人类提示词可以用任何语言要求继续;模型读取已停止目标并调用目标工具的 resume 动作,记录新修订并重新激活继续执行。 + +### 持久性与取消接缝 + +每次 `goal/changed` 通知都会产生一个检查点义务。驱动器在预留工作前等待 `ctx.sessions.flush(session)`,随后检查是否出现了更新的变更、agent 生命周期变化或竞争提示词。轮次结束时的 flush 失败会在 `turn/end` 之后通过现有 `agent/error` 通知报告;即使并发的一次性注入已追加后续轮次,驱动器仍会找到该精确的已关闭轮次,把失败关联到精确尝试,并在下一次空闲决策前解除激活。 + +广义取消此前只在队列已清除或请求已中止后暴露结果。公共 agent 词汇现在新增只观察的 `agent/cancel-requested(agent, reason)`。具体循环仅在取消有效时发出该事件,并且发生在清除队列和中止步骤之前;融合通知会隔离失败,因此损坏的监听器不能否决取消。目标驱动器利用该边沿在循环销毁排队工作证据前清除预留。若该预留是排队中或已接纳的目标尝试,取消会持久暂停目标;若取消属于没有目标尝试的无关人类工作,则只移除进程内激活态。若暂停变更抛错,驱动器会回退到解除激活,避免已取消的自动工作重新启动。 + +该通知是协调事件,不是第二个停止 API。`Agent.cancel()` 仍是唯一的公共广义取消动词,空闲调用仍是无操作;若消费者依赖此接缝,自定义 `Agent` 实现就必须满足该事件顺序。 + +### 进程生命周期 + +`GoalService.disarm(agent)` 只移除进程内激活态。它不写会话事件、不改变修订号,也不发出目标变更。驱动器在加载到已有 agent、持久性存在不确定性以及卸载前调用该方法;之后的 `resume` 才是模型可见的持久激活边沿。 + +驱动器的事件监听器和静止关闭嵌套在同一个有序 Cordis effect 中。Cordis 会并发卸载同级 effect;若监听器和清理分别注册,异步 disposer 仍在排空时提示词栅栏就可能已被移除。组合 effect 会先关闭接纳、解除目标激活、取消已接纳尝试,并等待 agent 与驱动器都达到静止;之后才注销监听器。 + +紧邻插件开始卸载前,收件箱接纳可能赢得微任务竞争。在这种情况下,轮次甚至首个请求都可能已经开始,且该回合仍会持久计费;卸载一旦开始,取消就会中止它,不会再调度后续回合,目标保持 active 但 disarmed。若假装已经观测到的接纳从未发生,就会破坏回放计数。 + +## 测试 + +单元测试使用真实 agent loop 与会话服务,只对模型编写脚本。覆盖内容包括精确连续接纳和上限执行、加载与恢复的惰性、所有结果分类、限流、请求错误、最大 token、下游提示词否决、接纳前与执行中取消、无关人类工作取消、暂停失败回退、人类输入排序、排队时与下游修订竞争、伪造目标来源、变更与轮次检查点失败(包括后续一次性注入)、调度器与自定义 agent 失败、会话启动重置、精确生命周期退出,以及排队中和运行中的插件卸载。新驱动器源码达到逐文件 100% 语句、分支、函数和行覆盖率。 + +无密钥 ACP 快照通过 `cordis.yml` 挂载已发布的编辑器应用,以及真实目标领域、目标工具、目标驱动器、agent loop、持久化和回放适配器。一个人类轮次创建并检查一个两回合目标;第一个自动轮次正常停止,ACP 随后取消刻意停滞的第二个回合并记录持久暂停。规范化线协议和外部 JSONL 断言证明只有一个会话、回合来源依次为 `1, 2`、生命周期变更与回放计数精确,并且没有把 `echo-agent` 当作应用替身。 + +核心取消测试固定通知顺序与隔离:只有有效取消才会通知;观察者可以在清空收件箱前排入替代工作;抛错不能阻止后续观察者;空闲调用不会发出事件。 + +## 考虑过的替代方案 + +- **在 `dsh-agent-loop` 内添加目标循环**——不予采纳,因为公共队列、提示词、会话、取消和状态接缝已经足够,具体循环分支还会赋予某种策略特权。 +- **使用 `agent/turn-continuation` 把每个回合变成另一个步骤**——不予采纳,因为目标回合是外层策略迭代,必须拥有自己的持久用户提示词、轮次边界、回合计数和失败结算。 +- **持久化待处理预留**——不予采纳,因为崩溃无法证明进程内队列已经达到接纳点;只有持久 `user/message` 才消耗回合。 +- **自动重试提供方或持久化错误**——不予采纳,因为重试会消耗资源,需要显式授权;停止阶段加之后的人类恢复更简单,也可观察。 +- **每回合 fork 对话历史或生成新 agent**——本包不采用,因为此目标明确属于同会话工作。新 agent 的 Ralph 执行仍是基于 subagent 与 workflow 原语的独立工作流插件。 +- **把每个会话轮次当作回合计数**——不予采纳,因为人类澄清和无关工作共享会话,但不共享自动工作预算。 + +## 后果 + +- 目标继续执行仍是可移除插件,具体循环只新增一个通用的“取消前观察”通知。 +- 回放可以从精确目标来源和提示词重建每个已接纳回合;被拒绝的预留不会产生虚假的预算消耗。 +- 人类消息和生命周期变更可以在有文档约束的竞争中胜出,而不破坏修订号或计数器。 +- 恢复和 fork 在语义上的人类意图促使模型记录 resume 变更之前始终保持惰性。 +- 保守的失败映射可能要求在暂时性错误后手动继续,但绝不会隐藏自动重试。 + +## 已知限制与延期工作 + +- 完成证据和阻塞条件的语义等价性仍由模型判断。独立评估器、完成证书或由验证器驱动的停止策略延期到独立策略插件。 +- 本包不提供 Ralph 风格的新 agent 尝试、上下文重置、跨回合评估反馈或工作流级并行;它们属于独立的 Ralph 工作流工具。 +- Cordis 卸载异步开始。已经被收件箱接受的条目可能先进入一个计费回合并启动一个请求,之后卸载取消才生效;关闭排空会阻止所有后续回合。 +- `maxGoalRounds` 只是已接纳回合上限。token、费用、挂钟时间和提供方使用预算需要独立策略。 +- 自定义 `Agent` 实现必须产生文档规定的会话事件、状态边沿、取消通知和静止语义;仅凭 TypeScript 结构兼容无法验证运行时顺序。 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index c31739a41b..f400701fcd 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -57,7 +57,7 @@ Normalization replaces session, cwd, protocol-id, timestamp, path, and process v ### Isolation: normalization now, sandbox later -Tool determinism comes from a temporary cwd, scrubbed environment, fresh non-login shell, constrained commands, and normalization. It does not claim OS confinement. A sandboxed executor can replace the local backend through the existing [capability seam](../architecture/2026-06-13-capability-seams.md) if a stronger tier is needed. +Tool determinism comes from a temporary cwd, scrubbed environment, fresh non-login shell, constrained commands, and normalization. Concurrent replay runs own separate cwd, persistence, and fixed-length scenario-keyed spill roots, so one scenario's teardown cannot delete another's in-flight full-output recovery while real-path preview budgets remain stable. This tier does not claim OS confinement. A sandboxed executor can replace the local backend through the existing [capability seam](../architecture/2026-06-13-capability-seams.md) if a stronger tier is needed. ### The replay plugin is its own package diff --git a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.md b/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.md deleted file mode 100644 index 97041b1c08..0000000000 --- a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.md +++ /dev/null @@ -1,343 +0,0 @@ -# Agent Note: harness-level goal-based loop - -Status: proposed - -English | [中文](2026-07-16-harness-level-loop.zh.md) - -## Problem - -`packages/core/agent-loop` runs only the inner loop: reasoning plus tool calls within one turn, ending when the model returns `end_turn`. Its README explicitly writes "No built-in turn budget"—budget is a gap it acknowledges itself. Cross-round scheduling falls on the harness layer: iterating until tests all pass, revising drafts against a rubric, splitting a PRD into beads and driving them one by one, running unattended for a whole night. None of these tasks has a first-class implementation today. - -The existing code offers three "just enough to run" alternatives, none of them adequate: - -| Alternative | Problem | -|---|---| -| A `packages/workflow` script expressing `while (!done)` | The README explicitly writes "No token-budget vocabulary" and "No journaling or resume"; the parent turn blocks until the script settles. Fine for orchestration lasting minutes, unusable for tasks lasting hours | -| An external shell `while :; do dsh-sdk …; done` | Ralph-style scheduling can be written this way today. It lacks a shared vocabulary for stop condition, budget, and evaluator, so every user reinvents them; the loop itself has no durable object for post-hoc diagnosis or recovery | -| The `sendMessage`/`resume` capabilities on the `packages/subagent` seam | The README explicitly writes "Runtime steering and continuation are seam-only capabilities". There is no model-facing consumer, so the model can only start a fresh subagent | - -Three typical use cases. **Automated fix**: a failing test suite in front of you, and you want a process to keep modifying code, running tests, and modifying again against the failure messages, until everything is green or the budget cap is hit. **Rubric-driven iterative revision**: a document, code, or translation must meet a set of scoring criteria; the loop repeatedly adjusts, an independent evaluator scores, and the loop stops when the criteria are met or the round budget is exhausted. **Unattended long runs**: for example, porting a repository from one tech stack to another overnight, kicked off before leaving work and reviewed the next morning, with the budget as the only safety net. Common shape across all three: minutes to hours, evaluator decides success, budget is a hard constraint, and post-run review and recovery are required. - -## Proposal - -**Loops come in four trigger shapes**, distinguished by who starts a round and when: - -| Shape | Who triggers | When | Existing comparable | This RFC | -|---|---|---|---|---| -| **turn-based** | The user sends a message in the session | Every user reply | `packages/core/agent-loop`'s existing reasoning-plus-tools cycle within one turn | Not covered; already implemented | -| **goal-based** | The user or the agent specifies "run until some condition" | One start, evaluator decides when to stop | Claude Code's `/goal`, Codex's `/goal`, the Ralph family | **This RFC covers it** | -| **time-based** | A scheduler | On cron or fixed interval | Claude Code's `/loop` (periodic), `/schedule` | Deferred to a `dsh-schedule` RFC | -| **proactive** | The agent itself | When the agent realizes during reasoning that a loop is needed | The proactive tier in Anthropic ClaudeDevs's four-way taxonomy | **Naturally included** (an agent calling the `loop` tool is already proactive) | - -This RFC only **adds a capability seam `packages/loop/`** for the goal-based shape. Proactive reuses the same `loop` tool—an agent invocation is a trigger by itself, with no extra machinery. Time-based needs an independent scheduler package and belongs to a separate RFC; this RFC only reserves a hook on the cordis leaf trigger surface for the future `dsh-schedule` integration. - -Three packages: - -- `@deepseek-ai/dsh-loop`: types, the `LoopDriver` service, the `StopCondition` discriminated union, the Phase 1 service definitions (`Evaluator` / `BudgetPolicy` / `RoundHandoff`), and the event schema; `GoalReflector` joins in Phase 2 with its first caller -- `@deepseek-ai/dsh-loop-driver`: the default driver implementation -- `@deepseek-ai/dsh-loop-tool`: the model-facing `loop` tool plus the CLI `dsh-sdk loop` - -The design is organized around four concrete problems, addressed by service seams or explicit driver policies in the phase where each has a caller: - -1. A long-running loop that goes wrong leaves no systematic diagnosis or recovery. **Loop as an independent session** addresses this. -2. Whether the PASS at loop end is trustworthy determines whether hours of work are wasted. An architecture where the same LLM both generates and self-evaluates is not trustworthy on its face. **Making Evaluator and Budget into service seams** addresses this. -3. Short and long tasks need opposite memory strategies; hardcoding one mode makes the other class of scenario unusable. **Making RoundHandoff into a service seam** addresses this. -4. The user's initial goal is not always correct. An agent stubbornly pursuing a wrong goal exhausts the budget doing wrong work. **Goal concern events and policies cover Phase 1; the GoalReflector service arrives with the Phase 2 `reflect` path**. - -Beyond the four seams, one principle threads through the whole document: **one loop handles one atomic goal**. Large goals should be split into several small loops chained in sequence, not stuffed into one loop with the evaluator judging multiple things. A rule of thumb for whether granularity is right: if you cannot say what a finished loop actually accomplished, granularity is too large and should be split. Phase 2 adds a `loop_split` model-facing tool so the agent can split an oversized goal itself. - -Terminology: **inner loop** refers to the existing per-turn reasoning-and-tools cycle in `packages/core/agent-loop`; **harness loop** refers to the outer scheduler introduced by this RFC, iterating around the inner loop. This RFC does not modify `agent-loop`, matching AGENTS.md's "Plugins, not loop changes". - -`StopCondition` is a discriminated union with `assertNever` closing the switch: - -```ts -interface EvaluatorReport { criteria: readonly { name: string; pass: boolean; evidence: readonly string[] }[] } - -type StopCondition = - | { kind: 'goal-met'; evidence: EvaluatorReport } - | { kind: 'budget-cap'; scope: 'usd' | 'tokens' | 'rounds'; observed: number; maximum: number } - | { kind: 'stuck'; pattern: 'repeat-action' | 'no-progress' | 'error-loop' } - | { kind: 'approval-required'; reason: string } - | { kind: 'user-cancel' } - -export {} -``` - -### Loop as an independent session - -Once a long-running loop goes wrong, the user has no systematic diagnostic method. A failure hours in leaves only scattered log files to sift through. Discovering that some middle round went off track and wanting to roll back to re-run means starting over from scratch. An agent wanting to consult its own experience from past loops has no API to reach it. - -The driver opens an independent loop-session (a new session id) for each loop. Every round's inputs, inner-loop results, evaluator reports, and stop decisions are persisted as session events, reusing the SQLite backend from `packages/session-persistence`. This yields three diagnostic and replay capabilities. - -- **Replay conversation from a recorded round**: while the source session is live, discover round 78 went off and fork the round-77 event prefix with a different prompt or evaluator; persisted replay needs a separate trusted load-and-seed path. Both forms replay conversation state against the current workspace, not the files and external side effects that existed at round 77 -- **Post-hoc diagnosis**: through the existing `ctx.sessionQuery` exact-read service, inspect the round where the evaluator started hanging on the same criterion -- **Meta-loop learning**: the proposed [SQLite FTS5 search](2026-07-10-sqlite-session-query-provider.md) can later find related historical loops before a new run—"have I fixed a similar bug before? Which round did it fail on?" - -Claude Code's and Codex's `/goal` are one-off objects: discarded when the run ends, so the agent starts from zero when facing a similar problem again. - -**Storage and recovery boundary**. A few KB of events per round, roughly 100–500 KB per 100-round loop; thousands of loops reach GB scale. Mitigated by the `logDetail: 'summary' | 'full'` config, defaulting to `full` with long-run users able to switch to `summary`. Persisting all intermediate state also writes generated keys, passwords, and similar secrets to disk—the same class of risk as an ordinary session but amplified 10–100×, and the README calls this out clearly. Exact live and persisted reads already exist through `ctx.sessionQuery`; FTS5 is an optional discovery improvement, not a Phase 1 dependency. Exact execution-world restore is not promised: `SessionStore.fork()` accepts a live session, and session events do not restore files, processes, environment, or external side effects. Restoring those requires a separate Git/worktree/checkpoint design. - -### Pluggable Evaluator and Budget - -A loop's value ultimately depends on whether the final PASS is trustworthy. If the evaluator can be hacked or hallucinates PASS, hours of work are wasted. An architecture where the same LLM both generates and self-evaluates is not trustworthy on its face: the model has the means to talk itself into PASS. Even letting an independent subagent be the evaluator only mitigates the problem; as long as the evaluator is still an LLM, it retains a systematic bias for the same class of content—an independent subagent is a mitigation, not a cure. - -Trustworthy evaluation needs both a deterministic judgment mechanism and an isolation boundary appropriate to the threat model: shell exit code, static analysis, or an external service avoids LLM self-judgment, while a separate worktree, read-only mount, container, or remote service prevents the worker from rewriting evaluator inputs. Only the user knows which checks and boundary to use: `pytest` commands differ by project, companies have private compliance checkers, and some teams also run internal lint. No number of built-in evaluators can cover them all. Evaluator therefore must be a seam the user can plug into. - -Budget is the same story: product-level spending guardrails are opaque, and cannot be adjusted for team policy (personal card, team splitting, per-PR settlement). - -`Evaluator` and `BudgetPolicy` are both exposed as cordis service seams, with users injecting them as plugins. `Goal` must carry an `EvaluatorSpec` at an explicit tier; the driver refuses to start a loop without a paired evaluator—vague goals ("write good code") cannot enter the loop system: - -```ts -interface RubricItem { name: string; description: string } -interface EvaluatorContract { readonly name: string } - -type CriteriaSpec = - | { kind: 'single-metric'; name: string } - | { kind: 'rubric'; criteria: RubricItem[] } - | { kind: 'contract'; interface: EvaluatorContract } - -type ExecutorSpec = - | { kind: 'shell'; command: string } - | { kind: 'llm-judge'; rubric: string; model: string } - | { kind: 'provider'; name: string; config?: unknown } - -type IsolationSpec = 'same-workspace' | 'separate-worktree' | 'container' | 'remote' - -interface EvaluatorSpec { - criteria: CriteriaSpec - executor: ExecutorSpec - isolation: IsolationSpec -} - -export {} -``` - -**Why explicit dimensions instead of letting the user pass any function?** The spec forces the user, at start time, to declare what is judged, what executes the judgment, and what isolation boundary protects it. A free function looks flexible, but in practice it lets evaluator strength quietly regress—the user thinks they're doing a deterministic isolated check when they've actually written a same-workspace LLM judgment. In long-run scenarios the cost is hours wasted. - -Criteria shape, executor, and isolation are orthogonal rather than a trust ladder: a rubric may be checked by shell, an LLM, or an external service, and a contract may run in the same workspace or in a container. `llm-judge` remains the weakest executor for soft goals where there truly is no hard signal (revision, translation quality). The documentation explicitly notes "this executor cannot defend against targeted adversarial input; long unattended runs require at least one deterministic evaluator with an isolation boundary appropriate to the threat model". - -The driver enforces four structural constraints, not delegated to Evaluator implementations. Isolation strength remains an explicit property of the configured provider rather than a claim the driver can manufacture. - -**Preventing "the same agent both generates and self-evaluates"**: - -1. **fresh subagent for LLM evaluation**: an LLM evaluator opens a fresh subagent (via `dsh-subagent`'s `spawn`) each round, not inheriting the main loop's context - -**Preventing the evaluator subagent itself from being subverted**: - -2. **scoped tool set**: an LLM evaluator's model-facing tool set is stripped by the driver to reads only, with write tools disabled (reusing `SubagentProvider`'s `toolFilter`). This reduces accidental mutation but is not process isolation: shell, code runtimes, or another capability can still write unless the configured isolation boundary prevents it - -**Preventing the evaluator report itself from deceiving the driver**: - -3. **PASS can only flip via an evaluator report**: the `goal-met` StopCondition can only come from the evaluator; the driver and the main agent cannot construct it directly -4. **Default-FAIL**: the driver maintains each criterion's pass state at `false` internally; only an evaluator report with non-empty evidence is allowed to flip it to `true`. The evaluator cannot get the driver to accept a `{pass: true}` return with no evidence - -Together, the four ensure that evaluator conclusions are structurally evidence-driven rather than confidence-driven. They do not stop the main agent from modifying evaluator inputs in a shared workspace. - -**Phase 1 ships three backends**: - -- `loop-evaluator-shell` implements `single-metric`: runs a shell command, `exit 0` = pass -- `loop-evaluator-rubric-judge` implements `llm-judge`: a prewritten rubric plus LLM scoring, soft goals only -- `loop-budget-preflight`: before each round starts, estimate `(promptTokens + overhead + estOutputTokens) / 1M × pricePerMTok`; refuse to start if the estimate exceeds `perRoundUsd`. The estimation model comes from MartinLoop `policy.ts:551-596` - -A `PricingProvider` service injects the pricing table; a test seam can override it, and it is not hardcoded into the driver (AGENTS.md "No hardcoded tunables in plugins"). A resolved budget carries `maxRounds`, optional `maxTokens` and `maxUsd`, and optional `perRoundUsd`. The driver checks per-round admission before starting work, then accumulates worker, evaluator, compaction, and reflector usage after every request. A token or USD cap may overrun by one in-flight request because usage arrives after completion; the `budget-cap` result reports `observed` and `maximum`. The `rubric` and `contract` criteria shapes get built-in executors in Phase 2; Phase 1 exposes the shapes so third-party plugins can integrate first. - -**Limitation**: `same-workspace` plus a read-only model-facing tool set is not hard isolation. The current `packages/fs` policy enforces read-before-edit and version guards, not path denial, and bash or code runtimes can bypass filesystem tools. Defending against targeted adversarial input requires a boundary across every mutation channel—such as a read-only mount, isolated worktree, container, or remote evaluator. The two-container approach (the evaluator's definition files are entirely inaccessible to the main agent, the route Anthropic patch.py takes) remains a Phase 3 item. See Risks. - -### Pluggable RoundHandoff - -How context passes between rounds is a dilemma. Preserving the full prior conversation (continue) reads more coherently, but the conversation keeps growing and eventually hits the context ceiling, and errors from a prior round contaminate every subsequent round. Starting each round from scratch (fresh) avoids the contamination, but has to re-understand context every time. A 3-round revision loop and an 80-round overnight bug-fix loop need opposite strategies. Claude Code and Codex both hardcode one mode, so users cannot switch by task type. - -Made a service seam: - -```ts -interface ContinuationRun { - readonly id: string - resume?(prompt: string): Promise -} - -interface PreviousRound { - result: unknown - evaluator: { criteria: readonly { name: string; pass: boolean; evidence: readonly string[] }[] } - tokenUsage: number - summary: string - sessionId: string - run?: ContinuationRun -} - -interface RoundContext { loopId: string; round: number; previous: PreviousRound } - -type NextRoundSpec = - | { mode: 'fresh'; prompt: string } - | { mode: 'continue'; run: ContinuationRun; prompt: string } - -interface RoundHandoff { - buildNextRound(prev: RoundContext, signal: AbortSignal): Promise -} - -export {} -``` - -Phase 1 ships the fresh backend; Phase 2 adds the two continuation backends after provider continuation exists: - -| Backend | Phase | Scenario | Mechanism | -|---|---|---|---| -| `handoff-fresh-with-summary` (default) | Phase 1 | Long runs, unattended | Open a fresh subagent each round, injecting only a progress summary as a system prompt append | -| `handoff-continue-with-compaction` (recommended middle) | Phase 2 | Medium length, 5–20 rounds | Retain the full conversation up to a token threshold; over the threshold, reuse [`packages/compact`](../../../../packages/compact/README.md) to compress into a summary, using summary + last K rounds as the starting point | -| `handoff-continue-raw` (advanced) | Phase 2 | ≤5 rounds, short tasks, testing | Plain continuation without truncation | - -**Why default to fresh?** Every long-run loop that actually succeeded (repomirror, Kimi ralph-loop, autoresearch) uses fresh. Placing important loop state outside the context window under driver management is the correct posture for long runs. `handoff-continue-raw` violates this experience, and the README explicitly notes it is not suitable for long runs. - -**Why is only this repo able to build the middle tier?** `handoff-continue-with-compaction` depends on a compaction seam—the competitors don't have one; only this repo's `packages/compact` provides that infrastructure. - -**Why a seam rather than a three-choice flag?** Users can write 20-line plugins expressing hybrid strategies like "continue for the first 5 rounds, then fresh", or "auto-compact once when context hits 50%", without waiting for main-library support. - -**Limitation**: `continue-with-compaction` depends on the compression quality of `packages/compact`; compression itself may write hallucinated information into the summary and propagate it forward. The README recommends fresh for long runs. The three backends' boundaries may confuse new users about which to pick; the `dsh-sdk loop` CLI defaults to fresh, so users don't have to understand the differences before hitting a concrete problem. - -### Pluggable GoalReflector - -The goal the user gives at loop start is not always accurate. It may be based on a wrong assumption (asking the agent to implement a feature with a since-deprecated API), it may not be clear enough (the agent discovers a clarification is needed only mid-work), or it may be invalidated by later information. Current loop-execution frameworks treat the goal as a contract frozen at start; the agent can only push down the original path, and the result is exhausting the budget on the wrong direction. - -Phase 2 makes this a service seam, with responsibility separated from `Evaluator`: the evaluator asks "did we reach the goal", the reflector asks "is the goal still the same goal". Phase 1 carries concern events plus the `stop` and `notify-continue` driver policies without registering an unused `GoalReflector` service. - -```ts -interface RoundContext { loopId: string; round: number } -interface GoalConcern { concern: string; severity: 'low' | 'medium' | 'high' } - -interface GoalReflector { - reflect(ctx: RoundContext, concerns: GoalConcern[]): Promise -} - -type GoalReflection = - | { kind: 'continue' } // goal 仍有效 - | { kind: 'revise'; newGoal: string; why: string } // 建议修正 goal - | { kind: 'stop-for-human'; reason: string } // 需要人拍板 - -export {} -``` - -**Concerns have three sources**. Phase 1 ships the first two; the `GoalReflector` service and periodic source arrive together in Phase 2: - -- **Agent-initiated**: via the model-facing tool `loop_flag_concern({ concern, severity })`. An agent that realizes during investigation that "the library the user assumed has been deprecated" can raise directly -- **Driver heuristic**: when budget passes 50% and zero criteria have passed, the driver auto-raises a `no-progress-toward-goal` concern -- **Periodic reflector subagent** (Phase 2): every N rounds, run an independent read-only subagent to re-audit goal validity, following the same isolation approach as the evaluator - -**Response strategy is controlled by the `onGoalConcern` config**. The four settings correspond to different philosophies about loop use; users choose by their team's collaboration style, and the driver takes no default stance: - -- `'stop'` (Phase 1 default): any concern triggers `StopCondition: approval-required`, and a human decides. A loop should never proceed on its own in the face of uncertainty—suitable for cautious teams and for high-impact loop scenarios -- `'notify-continue'` (Phase 1): record an ordinary `loop/goal-concern` session event, then continue; a human reviews at the end. ACP has no general high-priority marker, so dedicated concern rendering is deferred with the ACP command infrastructure. The loop internal is not interrupted—suitable for unattended long runs -- `'reflect'` (Phase 2): call `GoalReflector` to decide continue, revise, or stop. Delegates the initial judgment to an independent agent in place of a human—suitable for teams with moderate autonomy -- Not registering a `GoalReflector` and leaving `onGoalConcern` unset = the most hands-off tier: the loop stops only on traditional stop conditions - -**Why default to `stop`?** In unattended scenarios, stopping one extra time is safer than running for hours in the wrong direction. Users who explicitly want unattended can switch to `notify-continue`. - -A concern is itself just an ordinary session event, composing naturally with the persistence capability described earlier: a later replay can seed a new conversation from the round where the concern surfaced and swap the goal. This does not roll the workspace back to that round. - -**Abuse and loss protection**. An agent could raise a concern every round; the mitigation is the `severity` field plus a minimal rate limit on the driver side (same-concern dedup within 30 seconds). The cost of that abuse is that the agent stalls itself and cannot make progress, so the incentive is weak. Once a goal has been revised, the original goal is lost; each revise persists a `loop/goal-revised` session event with rationale, and later replay can select any historical goal version without claiming workspace restoration. - -### User surface - -Four trigger surfaces share one driver: - -- **Agent-side tool**: `loop({ goal, evaluator, maxRounds, maxUsd, onGoalConcern })` registers `kind: 'loop'` through `ctx.tasks`, returns the task id immediately, and runs the harness loop in the background. `task_output`, `task_list`, and `task_kill` provide collection and cancellation. Inside a running loop, the internal agent can call `loop_flag_concern({ concern, severity })` to raise a concern proactively. ACP rendering intent is `generic`. An agent-initiated call is proactive triggering with no extra machinery -- **CLI**: `dsh-sdk loop --stop --max-rounds N --max-usd X --handoff fresh` is human-initiated startup, the most typical Ralph-style usage -- **cordis leaf**: declare a resident loop as a leaf in `cordis.yml`, with future `dsh-schedule` RFC integration for periodic triggering -- **ACP slash command**: `/loop ` (and `/loop-flag-concern`) starts directly from within the editor or client's current session. Semantically equivalent to a human typing `dsh-sdk loop` in a shell, but happens within the ongoing ACP session context, letting the loop result inject back into the session - -The ACP slash command depends on: `packages/ui/acp`'s `available_commands_update` surface is currently unbuilt ([acp-feature-support.md](../../../../packages/ui/acp/acp-feature-support.md)). Once the harness's slash-command infrastructure lands, `/loop` and `/loop-flag-concern` only need to be registered against that infrastructure; the driver and tool interfaces do not change. This RFC reserves the names and specifies the argument shape, but does not commit the infrastructure itself—that belongs to a separate ACP catch-up RFC. - -The default system prompt carries two behavioral instructions, distributed with every built-in `loop` tool: - -1. No writing of `TODO`, `FAKE`, or `PLACEHOLDER` placeholders to superficially pass the evaluator -2. No writing of empty `try/except` or `catch(_)` blocks so the evaluator ignores errors - -Neither can be enforced at the seam layer; both are prompt-layer guidance and must not be described as hard constraints. Users may customize the system prompt; evaluators that require these rules must check them explicitly. - -### Relationship with existing code - -Direct reuse without modification: - -- `packages/subagent`'s `spawn` provider, `toolFilter`, and `persona`—the loop spawns a subagent per round; an LLM evaluator gets a scoped model-facing tool set, not a process-isolation guarantee -- `packages/tasks`—the model-facing loop is a `loop` task producer and reuses owner isolation, `task_output`/`task_list`/`task_kill`, completion notices, cancellation, and awaited cleanup -- The SQLite backend from `packages/session-persistence`—the loop-session persists -- `packages/session-query`—exact live and persisted session reads for post-hoc diagnosis -- `packages/compact`—the implementation basis for `handoff-continue-with-compaction` -- `packages/todo`—an optional progress representation in single-session continue mode -- If [ToolExecution.reportProgress](2026-07-13-stream-workflow-progress-through-tool-calls.md) lands first, the loop tool can use it for per-round UI updates - -Not touched: `packages/core/agent-loop` (the inner-loop semantics stay the same); `packages/workflow` (DAG orchestration vs. iterating one goal is an orthogonal relationship; the two READMEs cross-link in their "Related" section to describe the boundary). - -One dependency is not yet landed: - -- The ACP slash-command infrastructure (the `available_commands_update` surface)—see User surface. Before the infrastructure lands, the slash-command trigger is absent while the other three trigger surfaces work as normal - -The proposed [SQLite FTS5 search](2026-07-10-sqlite-session-query-provider.md) is an optional Phase 2 discovery improvement over the existing exact-read query service, not a dependency for Phase 1 event access. - -Continuation work can be deferred to Phase 2: the `SubagentRun.sendMessage` and `resume` methods exist as optional seam capabilities, but the current `subagent-spawn` provider deliberately exposes neither. The two `handoff-continue-*` backends therefore require provider implementations, capability checks, ownership tests, and a consumer surface—not only a new argument on `packages/subagent-tool`. Phase 1 ships only `handoff-fresh-with-summary` and does not touch subagent continuation. - -### Phasing - -**Phase 1** (the scope this RFC commits): the three-package seam; `StopCondition`; the orthogonal criteria/executor/isolation `EvaluatorSpec`, with built-in implementations for shell and LLM-judge execution and rubric/contract criteria shapes open for integration; Default-FAIL enforcement; evaluator and cumulative-budget backends; `handoff-fresh-with-summary`; `ctx.tasks` integration; the `loop_flag_concern` tool; the no-progress heuristic; the `onGoalConcern: 'stop' | 'notify-continue'` pair; the CLI; the tool; and the default system-prompt guidance. **Not included**: the SQLite FTS5 search surface, the ACP slash-command trigger surface (depends on the `available_commands_update` infrastructure), subagent continuation provider/tool work, the `GoalReflector` service, the stuck detector, the Reflector subagent, the `loop_split` tool, and built-in executors for every rubric/contract combination. - -**Phase 2**: the SQLite FTS5 search surface; the stuck detector (reproducing OpenHands's five patterns); subagent continuation provider implementations, capability checks, and consumer surface (unlocking the two continue handoffs); the `GoalReflector` service and Reflector subagent; the `onGoalConcern: 'reflect'` tier; the `loop_split` model-facing tool; and built-in executors for additional rubric/contract combinations. - -**Phase 3**: agent fleet (N parallel loops for the same goal, best result wins); integration with `dsh-schedule`; two-container evaluator isolation (evaluator definition files entirely inaccessible to the main agent, defending against reward hacking). - -## Alternatives considered - -**Extend `packages/core/agent-loop`**: add an "iterate on end_turn until goal" switch to the inner loop. Rejected—AGENTS.md says "new behavior goes on documented extension seams; changing agent-loop requires updating docs/architecture.md". The harness loop needs state across sessions and across agents; stuffing it into the inner loop tangles session semantics into two mixed layers. - -**Ship a single slash command `/loop` (Claude Code clone)**: minimal implementation. Rejected—the slash-command layer does not resolve the harness/inner boundary; the four design points (queryable session, tiered evaluator, pluggable handoff, pluggable goal reflector) have nowhere to sit at the slash-command layer, and every capability this RFC commits is lost. - -**Fully outsource to `packages/workflow`**: express the loop as a workflow node with a back edge. Rejected—workflow lacks first-class semantics for iteration, StopCondition, and Evaluator; forcing it means the evaluator has to masquerade as a phase, violating the architectural-isolation requirement that the evaluator be independent of the producer; the budget guardrail in workflow is phase-level rather than round-level, and the granularities do not match. - -**Hardcode a binary choice between A (fresh) and B (continue)**: the Ralph school and the LoopTroop school each have strong scenarios. Rejected—Pluggable RoundHandoff proposes a seam plus three built-in backends that cover both schools and allow hybrids. - -**Skip the evaluator seam, ship a few built-ins**: lighter. Rejected—the core value of Pluggable Evaluator and Budget is that team-private evaluators can extend the system. Hardcoding leaves long unattended users no option but to modify the main library. - -**Accept a free function that lacks an explicit `EvaluatorSpec`**: allow users to pass any `(result) => boolean`. Rejected—the criteria/executor/isolation dimensions force users to declare at start time what is judged, what runs the judgment, and what boundary protects it, preventing quiet regression to a weaker setup. A free function looks flexible but lets evaluator strength quietly regress, and the cost is heavy in long-run scenarios. - -**Introduce an independent memory engine (Beads / dex-style)**: an established approach to external state. Rejected—`packages/session-persistence` plus the existing exact-read `ctx.sessionQuery` already cover Phase 1 diagnosis, while SQLite FTS5 can add search later; the payoff of a new engine is far smaller than the maintenance cost. - -**Fold goal reflection into the Evaluator seam** (have the evaluator return "criteria are impossible"): rejected—it conflates "was the goal achieved" with "is the goal still correct", which are orthogonal concerns. `Evaluator` should stay independent, read-only, and simple. - -**Only ever add an event for goal-concern, no seam**: lighter. Phase 1 does use the event plus `stop`/`notify` policies; rejected as the final design because the Phase 2 `reflect` path needs a replaceable response strategy. The seam lands with that first caller rather than ahead of it. - -**Ship the full Reflector subagent in Phase 1**: more complete. Rejected—`loop_flag_concern` tool plus no-progress heuristic plus the two-policy `onGoalConcern` already covers 80% of scenarios; running an independent subagent every round is expensive, and introducing it on demand in Phase 2 is more sensible. - -**Do not ship `loop_split`; let users split themselves**: Phase 1 already does. Phase 2 adds it because long-run scenarios reveal that agents receiving an oversized goal will run it directly rather than split it, so explicit tool guidance is needed. - -## Acceptance criteria - -- The three packages `packages/loop/{loop,loop-driver,loop-tool}` are built as a capability seam; `dsh-loop` exports only types and registry -- `StopCondition` discrimination covers all branches (unit); `assertNever` closes the switch at compile time -- The Phase 1 services `Evaluator`, `BudgetPolicy`, and `RoundHandoff` can each be replaced by an external plugin (fixture: inject a mock implementation, driver calls it correctly); no `GoalReflector` service is registered before the Phase 2 `reflect` consumer exists -- `EvaluatorSpec`'s criteria/executor/isolation dimensions converge at compile time; the driver refuses to start a loop without a paired evaluator (fixture: `loop({ goal, evaluator: undefined })` returns a configuration error immediately) -- Default-FAIL fixture: when the evaluator report returns `{criterion, pass: true, evidence: []}`, the driver refuses that criterion flip and records an `evaluator/invalid-report` session event -- `RoundHandoff` receives the previous result, evaluator report, token usage, summary, session id, optional run handle, and cancellation signal; Phase 1's `fresh-with-summary` has unit coverage plus one pass-path e2e, while continuation backend tests wait for Phase 2 provider support -- `dsh-sdk loop` CLI e2e: given a goal plus a 3-round cap plus one shell evaluator, both the pass and exhaustion paths return a structured stop cause with a semantic exit code -- Evaluator scoping fixture: the main agent has fs.write while an LLM evaluator's model-facing tool set does not; the result and documentation still label `same-workspace` as non-isolated, and no `protectedPaths` guarantee is exposed -- Budget fixtures cover `perRoundUsd` admission plus cumulative `maxRounds`, `maxTokens`, and `maxUsd` across worker and evaluator usage; an in-flight overrun emits `budget-cap` with `observed` and `maximum` -- Goal concern fixture: `loop_flag_concern` is callable from the main agent and yields an ordinary `loop/goal-concern` session event; under `onGoalConcern: 'stop'` an `approval-required` StopCondition is emitted; under `'notify-continue'` the loop continues without nonexistent ACP priority metadata; the no-progress heuristic fires once when budget exceeds 50% with zero passes (with rate-limit dedup) -- The default system-prompt guidance (no TODO/FAKE/PLACEHOLDER, no empty catch) is distributed with the built-in `loop` tool, and a snapshot covers the prompt content without treating it as enforcement -- Each round's prompt, inner-loop result, evaluator report, and stop decision appear as session events and are readable through the existing exact-read `ctx.sessionQuery`; FTS5 search remains Phase 2 -- Model-facing loop startup returns a `loop` task id immediately; `task_output`, `task_list`, `task_kill`, parent-agent disposal, cancellation, producer reload, and service disposal cover owner isolation and awaited quiescence -- The "Related" sections in `packages/loop/README.md` and `packages/workflow/README.md` cross-link and describe the "when to use workflow vs. when to use loop" boundary clearly -- Unit 100% / snapshot / e2e / doc-sync / verify-module-graph / build / hygiene all green; the ACP rendering intent (`generic`) of the new tool has a snapshot - -## Risks - -**Conversation replay is not workspace restore**. Exact session reads already exist, and FTS5 improves historical discovery rather than enabling correctness. Replaying a round prefix against the current workspace can diagnose or redirect a run, but reproducing the execution world at that round requires Git/worktree/checkpoint support and an explicit policy for external side effects. - -**The boundary between `packages/workflow` and loop is a recurring FAQ**. "Is multi-round a loop or a workflow?"—both READMEs must state clearly: workflow is "steps known, agent to run undecided, parallel or serial orchestration"; loop is "agent decided, round count undecided, evaluator decides when to stop". Unclear docs cause users to pick the wrong one. - -**Evaluator reverse-optimization (reward hacking)**. In a sufficiently long loop, the agent can identify the evaluator's pattern and optimize against it—for example, discovering that "as long as `assert True` appears in a test file, it PASSes" and bypassing real completion that way. Phase 1's `same-workspace` mode does not prevent the agent from modifying tests or evaluator configuration through bash, code runtimes, or another write channel; the current `packages/fs` policy is not a path-isolation boundary. Users needing adversarial strength must choose an isolated worktree, read-only mount, container, or remote evaluator. Phase 3's two-container approach keeps the evaluator runtime (binary, rubric, dependency libraries) entirely inaccessible to the main agent, matching what Anthropic patch.py does. - -**Placeholder faking and over-defensive code**. Agents sometimes write `# TODO: implement` to sneak through a test, or write large amounts of `try/except: pass` to make the evaluator superficially PASS. These do not belong to the evaluator layer; they are prompt and training issues at the agent-generation stage. The two default system-prompt instructions in User surface are guidance only; users who add "static-check-forbid TODO and empty catch" rules to a custom evaluator get enforceable coverage. This class of problem cannot be cured at the seam layer. - -**Budget estimation drift and in-flight overrun**. Pricing can change, and cumulative token/USD usage becomes exact only after each worker, evaluator, compaction, or reflector request reports usage. Preflight protects a single round; cumulative caps stop the next request and may exceed the configured maximum by one in-flight request. The README reports both observed and maximum values and states that provider billing remains authoritative. - -**Background tasks are process-local**. `ctx.tasks` gives the model-facing loop owner isolation, generic collection/cancellation, completion notices, and awaited cleanup. Parent-agent or service disposal cancels and awaits the loop; a process crash cannot run cleanup, and durable restart remains outside Phase 1. - -**Long-run loop log growth**. A 100-round loop reaches MB scale for one session. `logDetail: 'summary'` is a safety net but Phase 1 defaults to `full`; Phase 2 adds summary semantics. - -**Pre-release allows direct evolution**. `SESSION_FORMAT_VERSION=0`; the `LoopRoundEvent` schema can change at any time. Backends reject old formats rather than maintain compatibility, matching the pre-release stance at the top of AGENTS.md. diff --git a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.zh.md deleted file mode 100644 index 6460254b07..0000000000 --- a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.zh.md +++ /dev/null @@ -1,343 +0,0 @@ -# Agent Note: harness 层 goal-based loop - -Status: proposed - -[English](2026-07-16-harness-level-loop.md) | 中文 - -## 问题 - -`packages/core/agent-loop` 只跑 inner loop:一次 turn 内推理加工具循环,模型返回 `end_turn` 就结束。其 README 明确写「No built-in turn budget」——预算是它自己承认的 gap。跨轮次调度落在 harness 层:跑到测试全绿、按 rubric 反复改稿、把 PRD 拆成 bead 逐个推进、无人值守跑一整晚。这几类任务今天都没有一等公民的实现。 - -现有代码里有三种「能凑合跑」的替代,都不够用: - -| 替代 | 问题 | -|---|---| -| `packages/workflow` 脚本表达 `while (!done)` | README 明写「No token-budget vocabulary」和「No journaling or resume」;父 turn 阻塞到脚本 settle。能跑几分钟的编排,跑不了几小时的长期任务 | -| 外部 shell `while :; do dsh-sdk …; done` | Ralph 风格的调度今天就能这么写。缺共享的 stop condition、budget、evaluator 词汇,每个使用者各自重发明;循环本身没有持久化对象可供事后诊断或恢复 | -| `packages/subagent` seam 的 `sendMessage`/`resume` | README 明写「Runtime steering and continuation are seam-only capabilities」。没有 model-facing consumer,模型只能起 fresh 子会话 | - -典型使用场景有三类。**自动化修复**:面前一个失败的测试套件,希望一个进程持续修改代码、跑测试、根据失败信息再修改,直到全绿或触达预算上限。**按 rubric 迭代改稿**:一份文档、代码或翻译需要满足打分标准,循环反复调整、独立评估者打分、直到达标或耗尽轮数。**无人值守长跑**:例如通宵把一个仓库从一种技术栈移植到另一种,下班前启动第二天回来看结果,全程只有预算兜底。三类共同的形态:几分钟到几小时、evaluator 决定成败、预算是硬约束、跑完还需要能回看和恢复。 - -## 提案 - -**Loop 有四种触发形态**,按谁在什么时候启动一轮划分: - -| 形态 | 谁触发 | 何时触发 | 现有对标 | 本 RFC | -|---|---|---|---|---| -| **turn-based** | 用户在会话里发一条消息 | 每一轮用户回复 | `packages/core/agent-loop` 现有一次 turn 内的推理与工具循环 | 不覆盖,已有实现 | -| **goal-based** | 用户或 agent 明确指定「跑到某条件为止」 | 一次启动,evaluator 判停 | Claude Code 的 `/goal`、Codex 的 `/goal`、Ralph 家族 | **本 RFC 覆盖** | -| **time-based** | scheduler | 按 cron 或时间间隔 | Claude Code 的 `/loop`(周期性)、`/schedule` | 延后到 `dsh-schedule` RFC | -| **proactive** | agent 自己 | agent 在推理中意识到需要开一个 loop 时 | Anthropic ClaudeDevs 4 类分类里的 proactive 档 | **本 RFC 自然包含**(agent 调 `loop` tool 就是 proactive) | - -本 RFC 只**新增 capability seam `packages/loop/`** 处理 goal-based 一种。proactive 复用同一 `loop` tool,agent 主动调用即触发,无需额外机制。time-based 需要独立的 scheduler package,属于另一份 RFC 的事情;本 RFC 只在 cordis leaf 触发面预留跟未来 `dsh-schedule` 联动的钩子。 - -三个包: - -- `@deepseek-ai/dsh-loop`:类型、`LoopDriver` service、`StopCondition` 判别联合、Phase 1 service 定义(`Evaluator` / `BudgetPolicy` / `RoundHandoff`)、事件 schema;`GoalReflector` 在 Phase 2 与首个调用方一起加入 -- `@deepseek-ai/dsh-loop-driver`:默认 driver 实现 -- `@deepseek-ai/dsh-loop-tool`:model-facing `loop` tool + CLI `dsh-sdk loop` - -设计围绕四个具体问题展开,在每项能力出现调用方的 phase 中通过 service seam 或显式 driver policy 解决: - -1. 长跑 loop 出问题后缺诊断和恢复手段。**loop 作为独立 session** 解决。 -2. loop 结束时的 PASS 是否可信决定几小时工作是否作废。同一个 LLM 既生成又自评的架构本身就不可信。**Evaluator 与 Budget 做成 service seam** 解决。 -3. 短任务和长任务需要的记忆策略相反,硬编一种模式会让另一类场景不可用。**RoundHandoff 做成 service seam** 解决。 -4. 用户初始给的 goal 未必始终正确。agent 沿着错的目标蛮干会耗尽预算做错事。**Phase 1 用 goal concern event 与 policy 处理;GoalReflector service 随 Phase 2 的 `reflect` 路径一起加入**。 - -四条 seam 之外还有一条贯穿全文的原则:**一个 loop 只处理一个原子目标**。大目标拆成若干小 loop 串联,不塞进一个 loop 让 evaluator 判定多件事。判定 granularity 是否合适的经验规则:如果 loop 跑完说不清它到底做完了什么,granularity 就太大,应当拆。Phase 2 补 `loop_split` model-facing tool 让 agent 收到过大 goal 时能自己拆。 - -术语约定:**inner loop** 指 `packages/core/agent-loop` 一次 turn 的推理与工具循环;**harness loop** 指本 RFC 引入的外层调度器,围绕 inner loop 反复迭代。本 RFC 不改 `agent-loop`,符合 AGENTS.md「Plugins, not loop changes」。 - -`StopCondition` 是 discriminated union,`assertNever` 收口: - -```ts -interface EvaluatorReport { criteria: readonly { name: string; pass: boolean; evidence: readonly string[] }[] } - -type StopCondition = - | { kind: 'goal-met'; evidence: EvaluatorReport } - | { kind: 'budget-cap'; scope: 'usd' | 'tokens' | 'rounds'; observed: number; maximum: number } - | { kind: 'stuck'; pattern: 'repeat-action' | 'no-progress' | 'error-loop' } - | { kind: 'approval-required'; reason: string } - | { kind: 'user-cancel' } - -export {} -``` - -### Loop 作为独立 session - -长跑 loop 一旦出错,用户没有系统的诊断手段。跑几小时后失败,只能翻散落的日志文件。发现中间某一轮走偏想倒回去重跑,只能从头开始。agent 想参考自己过去 loop 的经验也没有可用的 API。 - -Driver 为每个 loop 开一个独立的 loop-session(新的 session id)。每轮的输入、inner-loop 结果、evaluator 报告、stop 决策都作为 session event 落盘,复用 `packages/session-persistence` 的 SQLite backend。得到三种诊断与 replay 能力。 - -- **从已记录轮次 replay 对话**:源 session 仍 live 时,发现第 78 轮偏航,可以 fork 第 77 轮的 event prefix,换 prompt 或 evaluator;已持久化 session 的 replay 还需要独立的受信任 load-and-seed 路径。两者都只会基于当前工作区 replay 对话状态,不会恢复第 77 轮的文件与外部副作用 -- **事后诊断**:通过现有 `ctx.sessionQuery` 精确读取 service 检查 evaluator 从哪一轮开始一直挂在同条 criterion 上 -- **元循环学习**:拟议中的 [SQLite FTS5 search](2026-07-10-sqlite-session-query-provider.md) 后续可以在新 loop 启动前找到相关历史 loop——「我以前 fix 过类似的 bug 吗?失败在哪一轮?」 - -Claude Code、Codex 的 `/goal` 是一次性对象:跑完就丢,agent 下次遇到同类问题从零开始。 - -**存储与恢复边界**。每轮几 KB events,100 轮 loop 约 100–500 KB;跑几千个 loop 会到 GB 级。`logDetail: 'summary' | 'full'` 配置缓解,默认 `full`,长跑用户可切 `summary`。中间态全持久化会把生成过的 key、密码一并落盘,跟普通 session 是同一类风险但量放大 10–100 倍,README 明确提示。通过 `ctx.sessionQuery` 的精确 live 与已持久化读取已经存在;FTS5 是可选的发现能力增强,不是 Phase 1 依赖。本 RFC 不承诺精确恢复执行世界:`SessionStore.fork()` 只接受 live session,而 session event 不会恢复文件、进程、环境或外部副作用。这需要单独的 Git/worktree/checkpoint 设计。 - -### 可插拔的 Evaluator 与 Budget - -loop 的价值最终取决于结束时的 PASS 是否可信。如果 evaluator 会被 hack 或幻觉 PASS,前面几小时的工作全部作废。同一个 LLM 既生成又自评的架构本身就不可信:模型有条件说服自己 PASS。即便让独立 subagent 做 evaluator,只要 evaluator 还是 LLM,就仍然对同类内容有系统性偏好——独立 subagent 只是缓解不是根治。 - -可信评估同时需要确定性的判断机制,以及与 threat model 匹配的隔离边界:shell exit code、静态分析或外部服务避免 LLM 自评;独立 worktree、只读 mount、容器或远程服务防止 worker 改写 evaluator 输入。具体检查和边界只有用户知道:不同项目 `pytest` 命令不同、公司有私有合规检查器、有些团队还要跑内部 lint。主库无论内置几种都覆盖不全。所以 evaluator 必须做成用户可以自己接入的 seam。 - -预算方面同理:产品级的花费护栏是黑盒,无法按团队策略调整(个人卡、团队分摊、按 PR 结算)。 - -`Evaluator` 和 `BudgetPolicy` 都作为 cordis service seam 暴露。`Goal` 必须携带一个明确档位的 `EvaluatorSpec`,driver 拒绝启动没有 evaluator 配对的 loop——含糊的目标("把代码写好")不能进入 loop 系统: - -```ts -interface RubricItem { name: string; description: string } -interface EvaluatorContract { readonly name: string } - -type CriteriaSpec = - | { kind: 'single-metric'; name: string } - | { kind: 'rubric'; criteria: RubricItem[] } - | { kind: 'contract'; interface: EvaluatorContract } - -type ExecutorSpec = - | { kind: 'shell'; command: string } - | { kind: 'llm-judge'; rubric: string; model: string } - | { kind: 'provider'; name: string; config?: unknown } - -type IsolationSpec = 'same-workspace' | 'separate-worktree' | 'container' | 'remote' - -interface EvaluatorSpec { - criteria: CriteriaSpec - executor: ExecutorSpec - isolation: IsolationSpec -} - -export {} -``` - -**为什么使用显式维度,而不是让用户传自由函数?** spec 强制用户在启动时声明评估什么、由什么执行判断,以及什么隔离边界保护它。自由函数看起来灵活,实际让 evaluator 强度隐性下沉——用户以为在做确定性隔离检查,实际写的是同工作区 LLM 判断。长跑场景下代价是几小时白跑。 - -criteria shape、executor 与 isolation 是三个正交维度,不是可信度阶梯:rubric 可以由 shell、LLM 或外部服务检查,contract 也可以在同一工作区或容器中运行。`llm-judge` 仍是最弱的 executor,仅用于确实无硬信号的软目标(改稿、翻译质量)。文档明确标注「此 executor 不能挡定向对抗,长跑无人值守场景至少需要一个确定性 evaluator,并配合与 threat model 匹配的隔离边界」。 - -Driver 强制四条结构约束,不下放给 Evaluator 实现。隔离强度仍是已配置提供方的显式属性,不是 driver 能凭空制造的保证。 - -**防「同一个 agent 既生成又自评」**: - -1. **LLM 评估使用 fresh subagent**:LLM evaluator 每轮开 fresh subagent(用 `dsh-subagent` 的 `spawn`),不继承主循环 context - -**防 evaluator subagent 自身被 subverted**: - -2. **限制模型可见工具集**:LLM evaluator 的 model-facing tool set 被 driver 剥离到只保留读类工具,写类工具禁用(复用 `SubagentProvider` 的 `toolFilter`)。这会减少意外修改,但不是进程隔离:除非已配置隔离边界拦截,否则 shell、代码运行时或其他 capability 仍能写入 - -**防 evaluator 报告本身欺骗 driver**: - -3. **PASS 只能由 evaluator 报告翻转**:`goal-met` StopCondition 只能来自 evaluator,driver 或主 agent 都不能直接构造 -4. **Default-FAIL**:driver 内部维护每个 criterion 的 pass 状态默认 `false`,只有 evaluator 报告里带非空 evidence 才允许翻 `true`;evaluator 无法通过返回 `{pass: true}` 而不给 evidence 让 driver 接受 - -四条一起保证 evaluator 结论在结构上由证据推动,而不是由自信推动。它们不能阻止主 agent 在共享工作区中修改 evaluator 输入。 - -**Phase 1 内置三个 backend**: - -- `loop-evaluator-shell` 实现 `single-metric`:跑 shell 命令,`exit 0` = pass -- `loop-evaluator-rubric-judge` 实现 `llm-judge`:预写 rubric + LLM 打分,仅软目标 -- `loop-budget-preflight`:每轮启动前估 `(promptTokens + overhead + estOutputTokens) / 1M × pricePerMTok`,超 `perRoundUsd` 拒绝启动。估算模型来自 MartinLoop `policy.ts:551-596` - -`PricingProvider` 服务注入 pricing 表,test seam 可覆盖,不硬编到 driver 里(AGENTS.md「No hardcoded tunables in plugins」)。解析后的 budget 携带 `maxRounds`、可选 `maxTokens` 与 `maxUsd`,以及可选 `perRoundUsd`。driver 在启动工作前检查单轮准入,随后在每次请求后累计 worker、evaluator、compaction 和 reflector 用量。token 或 USD 上限可能被一个在途请求超出,因为 usage 在完成后才到达;`budget-cap` 结果同时报告 `observed` 与 `maximum`。`rubric` 与 `contract` criteria shape 在 Phase 2 补内置 executor,Phase 1 暴露这些 shape 让第三方插件先接。 - -**局限**:`same-workspace` 加只读 model-facing tool set 不是硬隔离。当前 `packages/fs` policy 实施 read-before-edit 与版本保护,不是路径拒写;bash 或代码运行时可以绕过 filesystem tool。挡定向对抗需要覆盖所有写入通道的边界,例如只读 mount、隔离 worktree、容器或远程 evaluator。两容器方案(evaluator 定义文件对主 agent 完全不可访问,Anthropic patch.py 走的就是这条路)仍在 Phase 3。见 风险。 - -### 可插拔的 RoundHandoff - -每轮之间如何传递 context 是一个两难。完整保留之前对话(continue)连续性好,但对话会持续增长最终撞上 context 上限,且上一轮的错误信息会污染后续每一轮。每轮从零开始(fresh)避免污染,但每次需要重新理解上下文。跑 3 轮改稿与跑 80 轮 overnight 修 bug 需要的策略是相反的。Claude Code、Codex 都硬编一种模式,用户没法按任务类型切换。 - -做成 service seam: - -```ts -interface ContinuationRun { - readonly id: string - resume?(prompt: string): Promise -} - -interface PreviousRound { - result: unknown - evaluator: { criteria: readonly { name: string; pass: boolean; evidence: readonly string[] }[] } - tokenUsage: number - summary: string - sessionId: string - run?: ContinuationRun -} - -interface RoundContext { loopId: string; round: number; previous: PreviousRound } - -type NextRoundSpec = - | { mode: 'fresh'; prompt: string } - | { mode: 'continue'; run: ContinuationRun; prompt: string } - -interface RoundHandoff { - buildNextRound(prev: RoundContext, signal: AbortSignal): Promise -} - -export {} -``` - -Phase 1 交付 fresh backend;Phase 2 在 provider continuation 存在后增加两个 continuation backend: - -| Backend | Phase | 场景 | 机制 | -|---|---|---|---| -| `handoff-fresh-with-summary`(默认) | Phase 1 | 长跑、无人值守 | 每轮开 fresh subagent,只注入一段 progress 摘要作 system prompt 附加段 | -| `handoff-continue-with-compaction`(推荐中间档) | Phase 2 | 5–20 轮的中等长度 | 整段对话保留到 token 阈值,超了复用 [`packages/compact`](../../../../packages/compact/README.md) 压缩,摘要 + 最近 K 轮作起点 | -| `handoff-continue-raw`(专业档) | Phase 2 | ≤5 轮短任务、测试 | 纯连续对话不裁剪 | - -**为什么默认 fresh?** 所有实际跑成的长跑 loop(repomirror、Kimi ralph-loop、autoresearch)用的都是 fresh。把重要 loop 状态放在 context window 外由 driver 管理是长跑的正确姿势。`handoff-continue-raw` 违反这条经验,README 明写长跑不适用。 - -**为什么中间档只有我们能做?** `handoff-continue-with-compaction` 依赖 compaction seam——竞品都没有,只有本仓库 `packages/compact` 提供了这个基础设施。 - -**为什么做成 seam 而不是三选一 flag?** 用户可以写 20 行插件表达「前 5 轮 continue、之后 fresh」这类混合策略,或表达「context 到 50% 自动 compact 一次」,不用等主库支持。 - -**局限**:`continue-with-compaction` 依赖 `packages/compact` 的压缩质量,压缩本身可能把幻觉信息写进摘要传下去;README 建议长跑首选 fresh。三个 backend 的边界会让新用户不知道选哪个;`dsh-sdk loop` CLI 默认用 fresh,用户在遇到具体问题前不需要理解这些差别。 - -### 可插拔的 GoalReflector - -用户在启动 loop 时给的目标不一定准确。可能基于错误假设(让 agent 用某个已经废弃的 API 实现功能),可能不够清晰(agent 在做的过程中才发现需要澄清),也可能被后来的信息证伪。现在的循环执行框架把 goal 当作启动时冻结的合约,agent 只能沿着原路蛮干,结果是在错的方向上耗尽预算。 - -Phase 2 把它做成 service seam,与 `Evaluator` 职责分离:evaluator 问「是否达成目标」,reflector 问「目标是否还是那个目标」。Phase 1 只携带 concern event,以及 `stop` 与 `notify-continue` driver policy,不注册没有调用方的 `GoalReflector` service。 - -```ts -interface RoundContext { loopId: string; round: number } -interface GoalConcern { concern: string; severity: 'low' | 'medium' | 'high' } - -interface GoalReflector { - reflect(ctx: RoundContext, concerns: GoalConcern[]): Promise -} - -type GoalReflection = - | { kind: 'continue' } // goal 仍有效 - | { kind: 'revise'; newGoal: string; why: string } // 建议修正 goal - | { kind: 'stop-for-human'; reason: string } // 需要人拍板 - -export {} -``` - -**concern 有三种触发来源**。Phase 1 实现前两种;`GoalReflector` service 与周期性来源一起在 Phase 2 加入: - -- **agent 主动**:通过 model-facing tool `loop_flag_concern({ concern, severity })`。agent 在调研中意识到「用户假设的那个库已经废弃」时可以直接 raise -- **driver 启发式**:预算过 50% 且零 criterion pass 时,driver 自动 raise `no-progress-toward-goal` concern -- **周期性 reflector subagent**(Phase 2):每 N 轮独立跑一个只读 subagent 复审 goal 有效性,与 evaluator 独立性遵循同一思路 - -**响应策略通过 `onGoalConcern` 配置项**。这四种配置对应不同的 loop 使用哲学,用户按团队协作方式选,driver 不预设立场: - -- `'stop'`(Phase 1 默认):任何 concern 都触发 `StopCondition: approval-required`,人拍板。loop 在遇到任何不确定性时都不应自己往下走,适合谨慎风格团队与影响面较大的 loop 场景 -- `'notify-continue'`(Phase 1):记录普通 `loop/goal-concern` session event 后继续跑,人在结束时集中审阅。ACP 没有通用高优先级 marker,因此专用 concern 渲染与 ACP command 基础设施一起后置。loop 内部不打扰,适合无人值守长跑 -- `'reflect'`(Phase 2):调 `GoalReflector` 决定 continue、revise 还是 stop。委派一个独立 agent 代替人做初步判断,适合中等自主度的团队 -- 不注册 `GoalReflector` 且 `onGoalConcern` 未设 = 最放手档,loop 只在传统 stop condition 触发时停 - -**为什么默认选 `stop`?** 无人值守场景下宁可多停一次也不要在错方向上跑几小时。用户明确要无人值守可切 `notify-continue`。 - -concern 本身就是普通 session event,跟前文的持久化 session 能力天然协同:后续 replay 可以从 concern 出现的轮次为新对话提供 seed,并替换 goal。这不会把工作区回滚到该轮。 - -**滥用与丢失防护**。agent 可能每轮都 raise concern;缓解是 `severity` 字段和 driver 侧的最小 rate limit(同一 concern 30 秒内去重)。这种滥用的代价是 agent 卡住自己无法推进,动机不强。goal 被 revise 后原始 goal 会丢失;每次 revise 落 `loop/goal-revised` session event 带 rationale,后续 replay 可选任意历史 goal 版本,但不承诺恢复工作区。 - -### 用户面 - -四个触发面共享同一个 driver: - -- **agent 侧 tool**:`loop({ goal, evaluator, maxRounds, maxUsd, onGoalConcern })` 通过 `ctx.tasks` 注册 `kind: 'loop'`,立即返回 task id,并在后台运行 harness loop。`task_output`、`task_list` 和 `task_kill` 负责收集与取消。正在跑的 loop 内部 agent 可用 `loop_flag_concern({ concern, severity })` 主动发起 concern。ACP 渲染意图为 `generic`。agent 自主发起就是 proactive 触发,无需额外机制 -- **CLI**:`dsh-sdk loop --stop --max-rounds N --max-usd X --handoff fresh`。人类主导启动,最典型的 Ralph 风格用法 -- **cordis leaf**:`cordis.yml` 里以 leaf 形式声明常驻循环,配合未来的 `dsh-schedule` RFC 可做周期性触发 -- **ACP slash command**:`/loop `(还有 `/loop-flag-concern`)在编辑器/客户端的当前会话里直接启动。语义等价于人类在 CLI 里敲 `dsh-sdk loop`,但发生在正在进行的 ACP session 上下文中,允许 loop 结果直接注入会话 - -ACP slash command 的依赖:`packages/ui/acp` 的 `available_commands_update` 面目前是 unbuilt 状态([acp-feature-support.md](../../../../packages/ui/acp/acp-feature-support.md))。等 harness 的 slash command 基础设施落地,`/loop` 与 `/loop-flag-concern` 只需在该基础设施里注册;driver 与 tool 接口不变。本 RFC 保留名字并给出参数 shape,但不承诺基础设施本身——那属于独立的 ACP 补齐 RFC。 - -默认 system prompt 里有两条行为指令,随所有内置 `loop` tool 一起分发: - -1. 不允许写 `TODO`、`FAKE`、`PLACEHOLDER` 占位符让 evaluator 表面通过 -2. 不允许写空的 `try/except` 或 `catch(_)` 让 evaluator 忽略错误 - -这两条无法在 seam 层强制,只是 prompt 层 guidance,不能描述成硬约束。用户可以自定义 system prompt;需要强制这些规则的 evaluator 必须显式检查。 - -### 与仓库现有代码的关系 - -直接复用无需修改: - -- `packages/subagent` 的 `spawn` provider、`toolFilter`、`persona`——loop 每轮起 subagent;LLM evaluator 获得受限的 model-facing tool set,不获得进程隔离保证 -- `packages/tasks`——model-facing loop 是 `loop` task producer,复用 owner isolation、`task_output`/`task_list`/`task_kill`、完成通知、取消和 awaited cleanup -- `packages/session-persistence` 的 SQLite backend——loop-session 落盘 -- `packages/session-query`——精确读取 live 与已持久化 session,用于事后诊断 -- `packages/compact`——`handoff-continue-with-compaction` 的实现基础 -- `packages/todo`——单会话 continue 模式下作为可选 progress 表达 -- 若 [ToolExecution.reportProgress](2026-07-13-stream-workflow-progress-through-tool-calls.md) 先落地,loop tool 可用它逐轮 UI 更新 - -不动:`packages/core/agent-loop`(inner loop 语义保持);`packages/workflow`(DAG 编排 vs. 迭代同 goal 是 orthogonal 关系,两个 README 在「Related」段互链说明边界)。 - -依赖尚未落地的一处: - -- ACP slash command 基础设施(`available_commands_update` 面)——见 用户面。基础设施落地前,slash command 触发面缺席,其它三个触发面照常工作 - -拟议中的 [SQLite FTS5 search](2026-07-10-sqlite-session-query-provider.md) 是现有 exact-read query service 之上的可选 Phase 2 发现能力增强,不是 Phase 1 event 访问的依赖。 - -Continuation 工作可以延后到 Phase 2:`SubagentRun.sendMessage` 与 `resume` 方法作为可选 seam capability 存在,但当前 `subagent-spawn` provider 明确不暴露这两个方法。因此,两个 `handoff-continue-*` backend 需要 provider 实现、capability check、ownership 测试和 consumer surface,不只是给 `packages/subagent-tool` 增加参数。Phase 1 只交付 `handoff-fresh-with-summary`,不改 subagent continuation。 - -### 分阶段 - -**Phase 1**(本 RFC 承诺范围):三包 seam;`StopCondition`;criteria/executor/isolation 三个正交维度的 `EvaluatorSpec`,其中 shell 与 LLM-judge execution 有内置实现,rubric/contract criteria shape 开放待接;Default-FAIL 强制;evaluator 与累计 budget backend;`handoff-fresh-with-summary`;`ctx.tasks` 集成;`loop_flag_concern` tool;no-progress 启发式;`onGoalConcern: 'stop' | 'notify-continue'` 二档;CLI;tool;默认 system prompt guidance。**不含**:SQLite FTS5 search 面、ACP slash command 触发面(依赖 `available_commands_update` 基础设施)、subagent continuation provider/tool 工作、`GoalReflector` service、stuck 检测器、Reflector subagent、`loop_split` tool,以及每种 rubric/contract 组合的内置 executor。 - -**Phase 2**:SQLite FTS5 search 面;stuck 检测器(复现 OpenHands 5 种模式);subagent continuation provider 实现、capability check 与 consumer surface(解锁两个 continue handoff);`GoalReflector` service 与 Reflector subagent;`onGoalConcern: 'reflect'` 档;`loop_split` model-facing tool;更多 rubric/contract 组合的内置 executor。 - -**Phase 3**:agent fleet(同 goal 派 N 个并行 loop 取最优);与 `dsh-schedule` 集成;两容器 evaluator 隔离(evaluator 定义文件对主 agent 完全不可访问,防 reward 反向优化)。 - -## 备选方案 - -**扩 `packages/core/agent-loop`**:给 inner loop 加「iterate on end_turn until goal」开关。拒绝——AGENTS.md「新行为走文档化扩展 seam;改 agent-loop 需要更新 docs/architecture.md」。harness loop 需要跨 session、跨 agent 的状态,塞进 inner loop 会把 session 语义拧成两层混合。 - -**只做一个 slash command `/loop`(Claude Code 复刻)**:实现最简。拒绝——slash-command 层不解决 harness/inner 边界;四条设计要点(可查询 session、分档 evaluator、可插拔 handoff、可插拔 goal reflector)在 slash-command 层没有承载点,本 RFC 承诺的能力全部丢失。 - -**全权外包给 `packages/workflow`**:把 loop 表达成带回边的 workflow 节点。拒绝——workflow 缺 iteration、StopCondition、Evaluator 的一等公民语义。硬用会把 evaluator 冒充成一个 phase,违反 evaluator 独立于 producer 的架构隔离要求;预算护栏在 workflow 是 phase-level 而非 round-level,粒度对不上。 - -**A(fresh)vs. B(continue)硬编二选一**:Ralph 派和 LoopTroop 派各自都有强场景。拒绝——可插拔的 RoundHandoff 提出 seam + 三档内置 backend 涵盖两派并允许 hybrid。 - -**不做 evaluator seam,内置几种够用**:更轻。拒绝——可插拔的 Evaluator 与 Budget 的核心价值是团队或私有 evaluator 可扩展。写死后长跑无人值守场景的用户只能改主库。 - -**接受不带显式 `EvaluatorSpec` 的自由函数**:允许用户传任意 `(result) => boolean`。拒绝——criteria/executor/isolation 维度强制用户在启动时声明评估什么、由什么执行判断、由什么边界保护,防止不知不觉滑到更弱的配置。自由函数看起来灵活,实际让 evaluator 强度隐性下沉,长跑场景代价大。 - -**引入独立记忆引擎(Beads / dex-style)**:外部化状态的成熟做法。拒绝——`packages/session-persistence` 加现有 exact-read `ctx.sessionQuery` 已经覆盖 Phase 1 诊断,SQLite FTS5 后续可以补 search;新引擎收益远小于维护成本。 - -**goal reflection 塞进 Evaluator seam**(让 evaluator 返回「criteria 不可能满足」):拒绝——混淆「是否成功」和「目标是否正确」两个正交问题。`Evaluator` 应保持独立、只读、简单。 - -**goal-concern 永远只做 event 不做 seam**:更轻。Phase 1 确实使用 event 加 `stop`/`notify` policy;作为最终设计仍拒绝,因为 Phase 2 的 `reflect` 路径需要可替换响应策略。seam 与首个调用方一起落地,不提前出现。 - -**Phase 1 就上完整 Reflector subagent**:更全。拒绝——`loop_flag_concern` tool + no-progress 启发式 + 二档 policy 覆盖 80% 场景;每轮跑独立 subagent 成本高,Phase 2 按需引入更合理。 - -**不做 `loop_split`,用户自己拆**:Phase 1 已经如此。Phase 2 加是因为长跑场景发现 agent 收到过大 goal 会直接跑而不是自己拆,需要显式工具引导。 - -## 验收标准 - -- `packages/loop/{loop,loop-driver,loop-tool}` 三包按 capability seam 建成;`dsh-loop` 只导 types 与 registry -- `StopCondition` 判别覆盖所有分支(单元),`assertNever` 编译期收口 -- Phase 1 的 `Evaluator`、`BudgetPolicy`、`RoundHandoff` 三条 service 都能被外部插件替换(fixture:注入 mock 实现,driver 正确调用);Phase 2 `reflect` consumer 出现前不注册 `GoalReflector` service -- `EvaluatorSpec` 的 criteria/executor/isolation 维度在编译期收敛;driver 拒绝启动没有 evaluator 配对的 loop(fixture:`loop({ goal, evaluator: undefined })` 立即返回配置错误) -- Default-FAIL fixture:evaluator 报告返回 `{criterion, pass: true, evidence: []}` 时 driver 拒绝该 criterion 翻转、记 `evaluator/invalid-report` session event -- `RoundHandoff` 接收上一轮 result、evaluator report、token usage、summary、session id、可选 run handle 和 cancellation signal;Phase 1 的 `fresh-with-summary` 有单元覆盖与一个 pass-path e2e,continuation backend 测试等待 Phase 2 provider 支持 -- `dsh-sdk loop` CLI e2e:给定 goal + 3 轮上限 + 一个 shell evaluator,通过与耗尽两条路径都返回结构化 stop cause 并 exit code 语义化 -- Evaluator scope fixture:主 agent 有 fs.write,LLM evaluator 的 model-facing tool set 没有;结果与文档仍把 `same-workspace` 标记为未隔离,不暴露 `protectedPaths` 保证 -- Budget fixture 覆盖 `perRoundUsd` 准入,以及跨 worker 与 evaluator usage 累计的 `maxRounds`、`maxTokens`、`maxUsd`;在途超限 emit 带 `observed` 与 `maximum` 的 `budget-cap` -- Goal concern fixture:`loop_flag_concern` 可从主 agent 调用并产出普通 `loop/goal-concern` session event;`onGoalConcern: 'stop'` 下 emit `approval-required` StopCondition;`'notify-continue'` 下继续跑且不携带不存在的 ACP priority metadata;no-progress 启发式在预算超 50% 且零 pass 时自动触发一次(rate-limit 去重) -- 默认 system prompt guidance(无 TODO/FAKE/PLACEHOLDER、无空 catch)随内置 `loop` tool 一起分发,snapshot 覆盖 prompt 内容但不把它当作强制机制 -- 每轮的 prompt、inner-loop 结果、evaluator report、stop 决策都以 session event 出现,并可通过现有 exact-read `ctx.sessionQuery` 读取;FTS5 search 留在 Phase 2 -- model-facing loop 启动后立即返回 `loop` task id;`task_output`、`task_list`、`task_kill`、父 agent dispose、取消、producer reload 和 service dispose 覆盖 owner isolation 与 awaited quiescence -- `packages/loop/README.md` 和 `packages/workflow/README.md` 的「Related」段互链清楚「何时用 workflow、何时用 loop」的边界 -- 单元 100% / snapshot / e2e / doc-sync / verify-module-graph / build / hygiene 全绿;新增 tool 的 ACP 渲染意图(`generic`)有 snapshot - -## 风险 - -**对话 replay 不是工作区恢复**。精确 session 读取已经存在,FTS5 改善历史发现能力,不决定正确性。基于当前工作区 replay 某一轮 prefix 可以诊断或改变运行方向,但复现该轮执行世界需要 Git/worktree/checkpoint 支持,以及针对外部副作用的显式 policy。 - -**`packages/workflow` 与 loop 的边界是持续答疑热点**。「多轮是 loop 还是 workflow」两个 README 必须写清楚:workflow 是「步骤已知、agent 未定、并串行编排」;loop 是「agent 已定、轮数未定、evaluator 判停」。文档不清晰会让用户混用错档。 - -**evaluator 反向优化(reward hacking)**。足够长的 loop 里 agent 有条件识别 evaluator 的模式并针对性优化,例如发现「只要测试文件里出现 `assert True` 就 PASS」从而绕过实质完成。Phase 1 的 `same-workspace` 模式不能阻止 agent 通过 bash、代码运行时或其他写入通道修改测试或 evaluator 配置;当前 `packages/fs` policy 不是路径隔离边界。需要对抗强度的用户必须选择隔离 worktree、只读 mount、容器或远程 evaluator。Phase 3 的两容器方案让 evaluator 整个运行时(二进制、rubric、依赖库)对主 agent 完全不可访问,Anthropic patch.py 走的就是这条路。 - -**占位符伪造与过度防御码**。agent 有时会写 `# TODO: implement` 让测试勉强通过,或写大量 `try/except: pass` 让 evaluator 表面 PASS。这些不属于 evaluator 层的问题,而是 agent 生成阶段的 prompt 与训练问题。用户面 段的两条默认 system prompt 指令只是 guidance;用户在自定义 evaluator 中加入「静态检查禁止 TODO 与空 catch」才能获得可强制覆盖。这类问题不是 seam 层能根治的。 - -**预算估算漂移与在途超限**。pricing 可能变化,累计 token/USD usage 只能在每次 worker、evaluator、compaction 或 reflector 请求报告 usage 后精确。preflight 保护单轮;累计上限会停止下一个请求,但可能被一个在途请求超出。README 同时报告 observed 与 maximum,并说明 provider 账单才是权威。 - -**后台 task 只存在于当前进程**。`ctx.tasks` 为 model-facing loop 提供 owner isolation、通用收集/取消、完成通知和 awaited cleanup。父 agent 或 service dispose 会取消并等待 loop;进程 crash 无法执行 cleanup,持久重启不在 Phase 1 范围内。 - -**长跑 loop 日志膨胀**。跑 100 轮 loop 单 session 上 MB 级。`logDetail: 'summary'` 兜底但 Phase 1 默认 `full`,Phase 2 再补 summary 语义。 - -**pre-release 允许直接演进**。`SESSION_FORMAT_VERSION=0`,`LoopRoundEvent` schema 可随时改;后端拒收旧格式而非兼容,与 AGENTS.md 顶部 pre-release stance 一致。 diff --git a/AGENTS.md b/AGENTS.md index fc39c69df3..0d74807bee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,9 +113,9 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. - **Every non-trivial change MUST include at least one Agent Note in the same PR.** Update the owning note or add one, validate its premises against code, and exempt only mechanical/local edits ([scope](.agents/notes/README.md#when-to-write-one)). -- **Testing policy** — [docs/testing.md](docs/testing.md). Transcript changes need snapshots or a PR note. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. +- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or human-visible change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). -- **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces, and schedule any missing harness support before implementation. +- **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --check` (pre-push) gates it. diff --git a/docs/architecture.md b/docs/architecture.md index 648a9a31b7..8dcf56bde3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,7 +119,7 @@ Pruning precedes summaries; overflow retries require durable progress. Bounded t The turn is the containment boundary. Adapter failures close the step, entering `agent/request-error` with the exact `Error`, `LlmFailure`, and retry history. Retry opens a numbered step; success clears history; exhaustion stores the failure on `turn/end`. Failed chunks commit no message or tool. -Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched model tool calls receive synthetic `tool/call` and `ABORTED` result pairs before `turn/end`. `cancel()` clears queues and aborts active work; disposal awaits quiescence before unregistering. +Other failures use `agent/error`. Cancellation beats recovery; undispatched calls get synthetic `ABORTED` results. Effective `cancel()` emits `agent/cancel-requested` before queue clearing or abort; observers cannot veto it, and idle calls emit nothing. Disposal awaits quiescence. Every session event is turn-enclosed. Reloading preserves an interrupted tail and closes it with a synthetic `interrupted` turn end. Failures after durable turn close report only through `agent/error` because no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. @@ -151,35 +151,36 @@ Streaming uses raw chunks and `BlockAssembler`. One `LlmAdapter.stream()` is one ### Capability Pattern -A swappable capability usually splits into **interface / implementation / consumer**: the interface owns its `ctx` key and events, an implementation registers a backend, and a consumer exposes model behavior through tools or prompts. Bash is the reference; the [capability graph](capability-seams.md) shows every family. +A swappable capability usually splits into **interface / implementation / consumer**: service/events, a backend, and model-facing tools/prompts. Bash is the reference; the [capability graph](capability-seams.md) maps each family. -Some seams bend the template deliberately: LLM combines interface and consumer because adapters implement it; filesystem wraps provider primitives with policy; web keeps search/fetch provider registries behind one service; skills and subagents use named providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). +Exceptions combine layers: LLM interface/consumer; filesystem policy; web registries; named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). `dsh-workspace-context` composes baselines on `agent/session-prefix` and appends `ctx.fs`-discovered nested changes on `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. ### Bundles And Apps -`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). `dsh-tui-demo` owns the interactive full-screen terminal; `dsh-cli-demo` runs one persisted headless turn with format-pure stdout; `dsh-acp-demo` adds stdout-pure ACP over JSON-RPC ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies its default only without an explicit config channel and drives `dsh-jsonrpc` over line-delimited JSON-RPC ([Python SDK](../python/README.md)). Deployments remain thin leaves with swappable backends and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` bundles the default spine and an opt-in persisted-goal stack ([README](../packages/examples/agent-spine-demo/README.md)). `dsh-tui-demo` owns the interactive full-screen terminal and enables goals plus `/goal` by default; `dsh-cli-demo` runs one persisted headless turn with format-pure stdout; `dsh-acp-demo` adds stdout-pure ACP over JSON-RPC and enables the same goal and command stack ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies its default only without an explicit config channel and drives `dsh-jsonrpc` over line-delimited JSON-RPC ([Python SDK](../python/README.md)). Deployments remain thin leaves with swappable backends and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Where New Behavior Goes -New behavior should attach to a documented extension point; changing the shipped loop requires updating this map. +New behavior attaches to a documented extension point; a loop change updates this map. | Goal | Mechanism | |---|---| | Add a model provider | register an adapter on `ctx.llm` | -| Add a model-facing capability | register a tool on `ctx.tools`; schemas flow into prompt assembly | -| Add command execution | implement and register a `ctx.bash` backend | -| Add a long-running/background capability | register the work on `ctx.tasks`; the generic `task_*` tools collect/stop it | +| Add a model-facing capability | register on `ctx.tools`; schemas enter prompt assembly | +| Add shell execution | implement and register a `ctx.bash` backend | +| Add a human command | register on `ctx.commands`; adapters discover and dispatch it without a model turn | +| Add background work | register on `ctx.tasks`; generic `task_*` tools collect or stop it | | Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events | | Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning | -| Intercept prompts, requests, model completion/failure, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` event; use serial `agent/turn-stop` for a monotonic terminal stop | -| Add a session-stable request prefix outside history | compose it on `agent/session-prefix`, once per loop instance; logged on the request header | +| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stop` is the serial terminal stop | +| Add a session-stable prefix outside history | compose `agent/session-prefix`; the request header logs it | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | | Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` | | Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` | -| Scope a tool, prompt section, or listener to ONE agent | register it through that agent's `agent.ctx` (see Agent Scope) | +| Scope a registration to one agent | use that agent's `agent.ctx` (see Agent Scope) | The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). diff --git a/docs/capability-seams.md b/docs/capability-seams.md index b663981d96..0cb39f9041 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -50,6 +50,8 @@ flowchart LR pkg_user_interaction["user-interaction"] svc_userInteraction["ctx.userInteraction
Human question/answer seam"] pkg_tui["tui"] + pkg_commands["commands"] + svc_commands["ctx.commands
Human command registry"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_local["skill-local"] @@ -88,6 +90,7 @@ flowchart LR pkg_subagent_spawn["subagent-spawn"] pkg_subagent_fork["subagent-fork"] pkg_subagent_acp["subagent-acp"] + pkg_tool_ralph["tool-ralph"] pkg_tasks["tasks"] svc_tasks["ctx.tasks
Background task registry"] pkg_tool_tasks["tool-tasks"] @@ -115,6 +118,7 @@ flowchart LR pkg_bash_sandbox --> svc_bash pkg_code_runtime --> svc_codeRuntime pkg_code_runtime_worker --> svc_codeRuntime + pkg_commands --> svc_commands pkg_compact --> svc_compact pkg_compact_basic --> svc_compact pkg_compact_tool_result_prune --> svc_toolResultPrune @@ -170,6 +174,8 @@ flowchart LR svc_bash --> pkg_hooks_codex svc_bash --> pkg_tool_bash svc_codeRuntime --> pkg_tools + svc_commands --> pkg_acp + svc_commands --> pkg_tui svc_compact --> pkg_compact_basic svc_fs --> pkg_tool_fs svc_invariants --> pkg_agent @@ -196,6 +202,7 @@ flowchart LR svc_sessions --> pkg_subagent_inprocess svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy + svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs @@ -220,6 +227,7 @@ flowchart LR svc_userInteraction --> pkg_tool_ask_user svc_userInteraction --> pkg_tui svc_web --> pkg_tool_web + svc_workflows --> pkg_tool_ralph svc_workflows --> pkg_tool_workflow svc_fs -. event gate .-> pkg_fs_policy ``` @@ -236,6 +244,7 @@ flowchart LR | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | +| `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | @@ -249,10 +258,10 @@ flowchart LR | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route. | | `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | -| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow) | - | One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents. | +| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cb2ff72753..37ea04d551 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -11,7 +11,7 @@ A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` ## `@deepseek-ai/dsh-acp` -Requires: `agents` · `sessionPersistence` · `tools` · `userInteraction` · `llm` · `systemPrompt` +Requires: `agents` · `commands` · `sessionPersistence` · `tools` · `userInteraction` · `llm` · `systemPrompt` ```ts config-catalog /** Plugin config: the agent template ACP sessions are created from. */ @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:207`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:247`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` @@ -68,6 +68,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable + /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ + goals?: agentCore.GoalConfig | false /** Bounded transient model-request retry policy forwarded through agent-core. */ llmRetry?: NonNullable } @@ -75,7 +77,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/acp-demo/src/index.ts:36`](../packages/examples/acp-demo/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:38`](../packages/examples/acp-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -118,9 +120,11 @@ Source: [`packages/core/agent-loop/src/index.ts:360`](../packages/core/agent-loo * order), the `tools` object to the tool registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the - * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. `llmRetry` configures bounded request recovery; - * `invariants` configures global and package-filtered relational checks. Owner schemas supply defaults for optional input; + * workspace-context loader, `llmRetry` to the bounded request-recovery policy, + * and `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * `goals` opts into and configures the persisted goal domain plus its model tool + * and same-session driver; `invariants` configures global and package-filtered + * relational checks. Owner schemas supply defaults for optional input; * workspace context instead requires an explicit byte budget or `false` because * it changes model-visible input. Producer opt-in stays producer-local: * `toolBash` configures bash only; independently composed producers keep their @@ -149,6 +153,8 @@ export interface Config { toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ invariants?: InvariantConfig + /** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */ + goals?: GoalConfig | false /** Bounded transient model-request retry policy. */ llmRetry?: llmRetry.Config } @@ -164,11 +170,19 @@ export interface SkillConfig { /** Model-facing skill catalog and tool settings. */ tool?: toolSkill.Config } + +/** Persisted goal domain, model-tool policy, and same-session driver config. */ +export interface GoalConfig { + /** Goal-domain creation defaults. */ + domain?: GoalDomainConfig + /** Model-facing goal-tool authority policy. */ + tool?: toolGoal.Config +} ``` -Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`llmRetry`](../packages/llm/llm-retry/src/index.ts) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) +Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`llmRetry`](../packages/llm/llm-retry/src/index.ts) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:65`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:78`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -1130,6 +1144,40 @@ export interface Config { Source: [`packages/fs/tool-fs-search/src/index.ts:62`](../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:27`](../packages/goal/tool-goal/src/index.ts) + +## `@deepseek-ai/dsh-tool-ralph` + +Requires: `tools` · `workflows` · `subagents` · `systemPrompt` + +```ts config-catalog +/** Deployment policy for the fixed Ralph workflow. */ +export interface Config { + /** Fresh structured-output provider used for every round (default `spawn`). */ + subagentProvider?: string + /** Default and deployment ceiling for one call's round count (default 256). */ + maxRounds?: number + /** Maximum serialized characters in one structured handoff (default 16384). */ + maxHandoffChars?: number + /** Maximum characters in a successful parent-facing terminal text (default 16384). */ + maxResultChars?: number +} +``` + +Source: [`packages/workflow/tool-ralph/src/index.ts:22`](../packages/workflow/tool-ralph/src/index.ts) + ## `@deepseek-ai/dsh-tool-skill` Requires: `tools` · `skills` @@ -1278,7 +1326,7 @@ Source: [`packages/core/tools/src/index.ts:382`](../packages/core/tools/src/inde ## `@deepseek-ai/dsh-tui` -Requires: `agents` · `userInteraction` · `tools` +Requires: `agents` · `commands` · `userInteraction` · `tools` ```ts config-catalog /** Serializable plugin configuration. */ @@ -1310,7 +1358,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:102`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:103`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` @@ -1345,6 +1393,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ toolTasks?: NonNullable + /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ + goals?: agentCore.GoalConfig | false /** Persisted session id to resume instead of creating a fresh session. */ resumeSessionId?: string /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ @@ -1354,7 +1404,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/tui-demo/src/index.ts:31`](../packages/examples/tui-demo/src/index.ts) +Source: [`packages/examples/tui-demo/src/index.ts:33`](../packages/examples/tui-demo/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -1551,7 +1601,10 @@ Source: [`packages/context/workspace-context/src/config.ts:16`](../packages/cont These load from a `cordis.yml` entry with no `config:` block; they declare no config surface. - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) +- `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts)) +- `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) +- `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 940fb81a0f..17b2346249 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -extension-cookbook.md: 32877b6170fd75ec901dda7cc0aec6b5a92e6cc6 -extension-cookbook.zh.md: d6bb6075b47867dcb5a848c835062ef4d9af5d45 +extension-cookbook.md: a1f6d2f0d27b2258ae06236721bbd80cbd3af80e +extension-cookbook.zh.md: f7729492b68bfef50d5e289581028d0c0c4164cf diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 32877b6170..a1f6d2f0d2 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -98,7 +98,7 @@ Every product feature maps to a listener on a documented extension seam — the | Product feature | Plugin mechanism | |---|---| | Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` — each interception waterfall returns a typed Decision; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | -| `/goal` | force-continue via `agent/turn-continuation` + `steer()` reminders | +| `/goal` | `ctx.goals` owns durable state, `dsh-goal-session` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control | | `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue | | Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and terminal `agent/turn-stop` | | Queued + steering messages | core `Agent.send()` / `Agent.steer()` | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index d6bb6075b4..f7729492b6 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -98,7 +98,7 @@ export function apply(ctx: Context) { | 产品功能 | 插件机制 | |---|---| | 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`agent/step-result`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation` 上的监听器——每个拦截 waterfall 返回一个类型化 Decision;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 | -| `/goal` | 通过 `agent/turn-continuation` 强制继续 + `steer()` 提醒 | +| `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-session` 通过公共 `Agent` 调度同会话回合,独立的命令/工具生产方分别提供人类/模型控制 | | `/loop` | 在 `turn/end` 会话事件上 `send()` 下一次迭代;或强制继续 | | 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 | | 排队消息 + steering(中途引导) | 核心 `Agent.send()` / `Agent.steer()` | diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 0390db1b43..a213c9e11a 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -13,6 +13,27 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n ## `agent/*` +### `agent/cancel-requested` — emit + +Effective broad cancellation was requested, before queued/steering work is cleared or the active step is aborted. This observe-only notification cannot veto cancellation; listener failures are contained. + +```ts cordis-catalog +/** + * Effective broad cancellation was requested, before queued/steering work + * is cleared or the active step is aborted. This observe-only notification + * cannot veto cancellation; listener failures are contained. + * @param agent - the agent whose current work is being cancelled. + * @param reason - resolved cancellation reason, including the default. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/cancel-requested'(this: Scoped, agent: Agent, reason: string): void +``` + +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:191`](../../packages/core/agent/src/types.ts) + ### `agent/created` — emit A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving seam. Synchronous listener failure vetoes publication, while returned-promise rejection is reported. Detach requested during dispatch waits until every creation listener has observed the stable entry. @@ -33,7 +54,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 +74,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 +96,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:315`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:328`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -98,7 +119,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:280`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -121,7 +142,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:220`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -142,7 +163,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:230`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -163,7 +184,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 +207,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:242`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -212,7 +233,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.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:282`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -238,7 +259,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:257`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -260,7 +281,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:204`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -280,7 +301,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 @@ -302,7 +323,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:268`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -323,7 +344,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:292`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -344,7 +365,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:302`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -390,6 +411,24 @@ Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalReques Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-approval/src/index.ts) +## `commands/*` + +### `commands/change` — emit + +A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. Observer failures are contained and cannot veto the registry mutation. + +```ts cordis-catalog +/** + * A command was registered or unregistered. This is an unfiltered registry + * notification because a global or scoped change may affect any UI view. + * Observer failures are contained and cannot veto the registry mutation. + * @mode emit + */ +'commands/change'(): void +``` + +Source: [`packages/ui/commands/src/index.ts:83`](../../packages/ui/commands/src/index.ts) + ## `fs/*` ### `fs/edit-intent` — waterfall diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 1531570776..9b7683408d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -340,6 +340,47 @@ Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResu Source: [`packages/code-runtime/code-runtime/src/index.ts:30`](../../packages/code-runtime/code-runtime/src/index.ts) +## `ctx.commands` — `CommandService` + +Human-command registry. Plain-context definitions are global; definitions registered through a command-injected child of an agent context shadow globals for that agent. + +```ts cordis-catalog +/** + * Register a global or calling-agent-scoped command. + * @param definition - discovery metadata and direct UI handler. + * @returns the exact effect disposer that unregisters this definition. + */ +register(definition: CommandDefinition): () => void + +/** + * List the effective immutable command descriptors for one agent. + * @param agent - exact receiving agent and scoped-layer key. + * @returns name-sorted descriptors after scoped shadowing. + */ +list(agent: Agent): readonly CommandDescriptor[] + +/** + * Resolve one effective command definition. + * @param agent - exact receiving agent and scoped-layer key. + * @param name - command name without a slash. + * @returns the scoped shadow or global definition. + */ +find(agent: Agent, name: string): CommandDefinition | undefined + +/** + * Parse and execute a known command without sending it to the model. + * @param agent - exact receiving agent. + * @param line - complete slash-command line. + * @param signal - cancellation signal owned by the UI request. + * @returns a detached result, or `undefined` when syntax or name does not resolve. + */ +async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise +``` + +Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md) · [CommandResult](../core-data-structures/commands.md) + +Source: [`packages/ui/commands/src/index.ts:207`](../../packages/ui/commands/src/index.ts) + ## `ctx.compact` — `CompactService` (abstract seam) Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. Load one implementation per context as `ctx.compact`. @@ -498,6 +539,15 @@ Goal service (`ctx.goals`) backed exclusively by the owning session log. */ get(agent: Agent): GoalView | undefined +/** + * Remove process-local continuation authority without changing durable goal + * phase or revision. Lifecycle owners use this before unloading a driver; + * a later human-authorized {@link resume} records the new activation edge. + * @param agent - owning live agent. + * @returns a fresh disarmed view, or `undefined` when no goal is current. + */ +disarm(agent: Agent): GoalView | undefined + /** * Create and arm a goal. A completed goal may be replaced; every other * current phase must be cleared or resumed instead. diff --git a/docs/core-data-structures/commands.md b/docs/core-data-structures/commands.md new file mode 100644 index 0000000000..c33b27ce1c --- /dev/null +++ b/docs/core-data-structures/commands.md @@ -0,0 +1,84 @@ +# Human Commands + +The human-command seam of [`dsh-commands`](../../packages/ui/commands). TUI and ACP adapters use it to discover and directly execute plugin-owned commands for an exact agent without creating a model message. The [command Agent Note](../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) owns dispatch and lifecycle rationale; the [package README](../../packages/ui/commands/README.md) owns composition and limitations. + +Source: [`packages/ui/commands/src/index.ts`](../../packages/ui/commands/src/index.ts) + +## Input metadata + +ACP currently exposes one unstructured-input hint. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition. + +```ts type-equiv +/** Immutable command input metadata compatible with ACP unstructured input. */ +interface CommandInputDescriptor { + /** Placeholder shown before the user supplies free-form input. */ + readonly hint: string +} +``` + +## Definition + +`CommandDefinition` is the plugin-authored registration. The registry validates and freezes a detached effective definition. + +```ts type-equiv +/** Plugin-owned command registration. */ +interface CommandDefinition { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor + /** Execute against the receiving agent without sending the command to the model. */ + readonly handler: (invocation: CommandInvocation) => CommandResult | Promise +} +``` + +## Invocation and result + +The adapter owns cancellation and passes the exact target agent. `rawInput` begins immediately after the parsed name and retains the adapter-delivered separator and suffix. Results are direct UI outcomes, not tool results or session events. + +```ts type-equiv +/** Invocation passed to one registered command handler. */ +interface CommandInvocation { + /** Exact agent whose human-facing surface received the command. */ + readonly agent: Agent + /** Exact text following the registered command name, including separator whitespace. */ + readonly rawInput: string + /** Cancellation signal owned by the dispatching UI request. */ + readonly signal: AbortSignal +} +``` + +```ts type-equiv +/** Expected command outcome rendered directly by the dispatching UI. */ +type CommandResult = + | { readonly kind: 'success'; readonly text?: string } + | { readonly kind: 'error'; readonly text: string } +``` + +## Discovery and parsing views + +Adapters receive handler-free immutable descriptors after scope resolution. `parseCommand()` returns `ParsedCommand` before registry resolution; syntax-valid input can still name an unavailable command. + +```ts type-equiv +/** Handler-free immutable command view returned to UI adapters. */ +interface CommandDescriptor { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor +} +``` + +```ts type-equiv +/** Syntactically valid slash command before registry resolution. */ +interface ParsedCommand { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Exact text following the command name. */ + readonly rawInput: string +} +``` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index c08af68eca..dfdc258cb0 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -19,6 +19,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [token-meter.md](token-meter.md) | immutable scalar and positional replay measurements with consumed-log revisions | | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | | [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution | +| [commands.md](commands.md) | the human-command seam: definitions, adapter discovery, direct invocation, results, and parsing views | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, and relationship traces | @@ -389,10 +390,10 @@ interface Agent { /** * Clear all queued and steering work, including items waiting to start, and - * abort the active step. The supplied reason is preserved across pre-step - * and active cancellation windows, and `whenIdle()` resolves after - * cancellation reaches quiescence. Idle cancellation is a no-op and does not - * arm a later cancel. + * abort the active step. An effective call first emits `agent/cancel-requested` + * with the resolved reason. That reason is preserved across pre-step and active + * cancellation windows, and `whenIdle()` resolves after cancellation reaches + * quiescence. Idle cancellation is a no-op and does not arm a later cancel. */ cancel(reason?: string): void diff --git a/docs/core-data-structures/workflow.md b/docs/core-data-structures/workflow.md index 26e337c4c6..8d8e47fc79 100644 --- a/docs/core-data-structures/workflow.md +++ b/docs/core-data-structures/workflow.md @@ -8,7 +8,7 @@ Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/work ## The start request -What a caller asks for when starting a run. The tool layer builds this from the model's `{ script, meta, args }` call plus the calling agent; `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)). +What a caller asks for when starting a run. The ordinary workflow tool builds this from the model's `{ script, meta, args }` call plus the calling agent; specialized consumers may also select one engine-wide `subagentProvider` and lower `maxTotalAgents` for the run, but the script cannot observe or replace either policy. `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)). ```ts type-equiv /** @@ -26,6 +26,17 @@ interface WorkflowStartRequest { meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown + /** + * Optional engine-wide child-provider override for this run. The workflow + * script cannot observe or replace it; omission uses the engine's configured + * provider. + */ + subagentProvider?: string + /** + * Optional per-run total-child ceiling. Implementations reject values above + * their deployment ceiling before publishing the run. + */ + maxTotalAgents?: number /** The agent on whose behalf the run executes (parent of every child). */ parent: Agent /** Cancels the run when aborted (the tool's `exec.signal`). */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b78f057fd4..59b069d8ad 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,30 +8,32 @@ 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:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:150`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`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), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:315`](../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:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-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) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:168`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`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:292`](../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:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:153`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:162`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:328`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:280`](../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:220`](../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:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:242`](../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:295`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:257`](../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:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:171`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:268`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:305`](../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:315`](../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) | +| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:83`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../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:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | - | +| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:44`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -55,7 +57,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/status` | - | [`agent`](../packages/core/agent) | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/glossary.md b/docs/glossary.md index c8ec6f378d..e290543d2c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -20,10 +20,22 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i - **goal** — one durable completion objective attached to an existing session, with a revisioned `active` / `paused` / `blocked` / `complete` phase and a goal-round cap; `blocked` retains a policy code and explanation. A goal is state, not a scheduler or a separate conversation; the session log remains its source of truth. - **goal round** — one continuation cycle admitted for the current goal. The same-session driver materializes a goal round as one goal-sourced [turn](#turn), which can contain multiple steps; unrelated human turns in the same session do not consume the goal-round cap. -- **goal activation** — process-local permission for a continuation consumer to admit another goal round. Activation is either `armed` or `disarmed`; it is deliberately absent from durable replay, so resume and fork require a later explicit resume mutation before automatic work. +- **goal activation** — process-local permission for a continuation consumer to admit another goal round. Activation is either `armed` or `disarmed`; it is deliberately absent from durable replay, so resume and fork require a later human-authorized resume mutation through `/goal` or the model tool before automatic work. + +## human command + +- **human command** — a slash-prefixed instruction interpreted and executed by a human-facing adapter through `ctx.commands`, without becoming a model message. It is distinct from a model-facing tool and from shell command execution through `ctx.bash`. +- **command plane** — discovery, parsing, dispatch, cancellation, and result rendering owned by UI adapters and command plugins. Command output is UI state unless the handler separately mutates a durable domain. +- **goal command** — the `/goal` human command contributed by `dsh-command-goal`; it observes or mutates the current goal directly while the goal domain owns every durable, model-visible record. ## loop hierarchy - **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. - **step** — one model request plus the tool executions caused by its response; a turn contains one or more steps. -- **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round). Round counters belong to that policy and do not count every turn in a session. +- **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round) or one fresh-agent Ralph attempt. Round counters belong to that policy and do not count every turn in a session. + +## Ralph + +- **Ralph loop** — one foreground fresh-agent workflow run toward an immutable objective. It is a model-facing tool policy composed from workflow and subagent primitives, not a same-session goal, agent-loop mode, scheduler, or generic workflow-script feature. +- **Ralph round** — one fresh child session in a [Ralph loop](#ralph-loop). The child receives no parent or prior-child conversation seed; the shared workspace and one bounded [Ralph handoff](#ralph-handoff) carry cross-round state. +- **Ralph handoff** — the normalized bounded structured report passed from one continuing Ralph round to the next, containing status, summary, evidence, next steps, and blocker text. It supplements the shared workspace rather than replacing it as authority. diff --git a/docs/module-graph.md b/docs/module-graph.md index 31e4e1c166..977662455b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -30,7 +30,10 @@ flowchart TD pkg_tools["tools"] end subgraph group_goal["packages/goal"] + pkg_command_goal["command-goal"] pkg_goal["goal"] + pkg_goal_session["goal-session"] + pkg_tool_goal["tool-goal"] end subgraph group_bash["packages/bash"] pkg_bash["bash"] @@ -110,6 +113,7 @@ flowchart TD subgraph group_ui["packages/ui"] pkg_acp["acp"] pkg_app_boot["app-boot"] + pkg_commands["commands"] pkg_jsonrpc["jsonrpc"] pkg_permission["permission"] pkg_tool_ask_user["tool-ask-user"] @@ -153,6 +157,7 @@ flowchart TD pkg_tool_tasks["tool-tasks"] end subgraph group_workflow["packages/workflow"] + pkg_tool_ralph["tool-ralph"] pkg_tool_workflow["tool-workflow"] pkg_workflow["workflow"] pkg_workflow_workerthread["workflow-workerthread"] @@ -286,6 +291,9 @@ flowchart TD pkg_session_query --> pkg_llm pkg_session_query --> pkg_session pkg_session_query --> pkg_session_persistence + pkg_commands --> pkg_agent + pkg_commands --> pkg_invariants + pkg_commands --> pkg_scope pkg_user_approval --> pkg_agent pkg_user_approval --> pkg_brand pkg_user_approval --> pkg_invariants @@ -316,6 +324,14 @@ flowchart TD pkg_tools --> pkg_session pkg_tools --> pkg_system_prompt pkg_tools --> pkg_user_approval + pkg_command_goal --> pkg_commands + pkg_command_goal --> pkg_goal + pkg_command_goal --> pkg_invariants + pkg_goal_session --> pkg_agent + pkg_goal_session --> pkg_goal + pkg_goal_session --> pkg_invariants + pkg_goal_session --> pkg_llm + pkg_goal_session --> pkg_session pkg_bash_sandbox --> pkg_bash pkg_bash_sandbox --> pkg_bash_local pkg_bash_sandbox --> pkg_invariants @@ -340,6 +356,13 @@ 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_invariants + 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 @@ -418,6 +441,7 @@ flowchart TD pkg_agent_loop_testkit --> pkg_tools pkg_acp --> pkg_agent pkg_acp --> pkg_bash + pkg_acp --> pkg_commands pkg_acp --> pkg_invariants pkg_acp --> pkg_llm pkg_acp --> pkg_llm_retry @@ -493,6 +517,7 @@ flowchart TD pkg_jsonrpc --> pkg_subagent pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop + pkg_tui --> pkg_commands pkg_tui --> pkg_invariants pkg_tui --> pkg_llm pkg_tui --> pkg_llm_retry @@ -501,6 +526,8 @@ flowchart TD pkg_tui --> pkg_user_interaction pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop + pkg_agent_spine_demo --> pkg_goal + pkg_agent_spine_demo --> pkg_goal_session pkg_agent_spine_demo --> pkg_home pkg_agent_spine_demo --> pkg_invariants pkg_agent_spine_demo --> pkg_llm @@ -512,10 +539,18 @@ flowchart TD pkg_agent_spine_demo --> pkg_system_prompt pkg_agent_spine_demo --> pkg_tasks pkg_agent_spine_demo --> pkg_tool_bash + pkg_agent_spine_demo --> pkg_tool_goal pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tool_tasks pkg_agent_spine_demo --> pkg_tools pkg_agent_spine_demo --> pkg_workspace_context + pkg_tool_ralph --> pkg_agent + pkg_tool_ralph --> pkg_invariants + pkg_tool_ralph --> pkg_llm + pkg_tool_ralph --> pkg_subagent + pkg_tool_ralph --> pkg_system_prompt + pkg_tool_ralph --> pkg_tools + pkg_tool_ralph --> pkg_workflow pkg_workflow_workerthread --> pkg_agent pkg_workflow_workerthread --> pkg_brand pkg_workflow_workerthread --> pkg_invariants @@ -535,6 +570,8 @@ flowchart TD pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot + pkg_acp_demo --> pkg_command_goal + pkg_acp_demo --> pkg_commands pkg_acp_demo --> pkg_invariants pkg_acp_demo --> pkg_session_persistence_jsonl pkg_acp_demo --> pkg_tools @@ -553,6 +590,8 @@ flowchart TD pkg_tui_demo --> pkg_agent_loop pkg_tui_demo --> pkg_agent_spine_demo pkg_tui_demo --> pkg_app_boot + pkg_tui_demo --> pkg_command_goal + pkg_tui_demo --> pkg_commands pkg_tui_demo --> pkg_invariants pkg_tui_demo --> pkg_llm pkg_tui_demo --> pkg_session @@ -618,16 +657,20 @@ flowchart TD | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | +| [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`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), [`invariants`](../packages/support/invariants), [`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), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`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) | @@ -640,7 +683,7 @@ flowchart TD | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | +| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -652,11 +695,12 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/testing.md b/docs/testing.md index c158c9b922..85601ab6c9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -37,4 +37,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Any change affecting an editor-facing transcript, headless event stream, or end-to-end agent UX adds or updates a scenario in the owning snapshot suite, or states in the PR why none applies. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it — a harness gap is scheduled work, not a mid-build surprise. +Every non-trivial model- or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 3caf1415fa..6196a71521 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -21,6 +21,8 @@ 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 .agents/notes/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 conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). 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-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.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/tui-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 +395,127 @@ Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-searc glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). 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 limit on automatic continuation rounds." + } + }, + "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, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. 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 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 +{ + "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." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "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-ralph` + +### `ralph` + +Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. + +```json +{ + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] +} +``` + +Source: [`packages/workflow/tool-ralph/src/index.ts`](../packages/workflow/tool-ralph/src/index.ts) + +A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. + ## `@deepseek-ai/dsh-tool-skill` ### `skill` diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index a598f109ad..7645e85265 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -47,6 +47,8 @@ flowchart LR cfg --> plugin_acp_workflow_workerthread plugin_acp_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] cfg --> plugin_acp_tool_workflow + plugin_acp_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] + cfg --> plugin_acp_tool_ralph plugin_acp_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] cfg --> plugin_acp_tool_todo plugin_acp_repeat_tool_guard["repeat-tool-guard
@deepseek-ai/dsh-repeat-tool-guard"] @@ -81,6 +83,7 @@ flowchart LR | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | +| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | | `tool-todo` | `@deepseek-ai/dsh-tool-todo` | | `repeat-tool-guard` | `@deepseek-ai/dsh-repeat-tool-guard` | | `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 7f4ef21a59..c70b41f9c7 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -115,6 +115,9 @@ - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' + +- id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' # `todo_write` replaces the logged whole list and surfaces an ACP `plan` update. - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 88ae18222c..aa3cba1f56 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -50,6 +50,8 @@ function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['m const SCENARIOS: Scenario[] = [ { name: 'handshake', hasModelTurn: false, recorded: false }, { name: 'reject-extra-dirs', hasModelTurn: false, recorded: false }, + // Direct command dispatch reports goal state without spending a model turn. + { name: 'goal-command-status', hasModelTurn: false, recorded: false }, // text-turn is the pinned-header scenario: the minimal single text turn. // Its prompt and tool-schema sidecars pin the composed header. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/input.json b/examples/acp-agent/tests/goal-snapshots/goal-session/input.json new file mode 100644 index 0000000000..93392c9e0f --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/input.json @@ -0,0 +1,12 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { + "op": "promptAndWaitForAgentMessage", + "text": "Create a durable two-round goal for the ACP snapshot, inspect it, then report readiness.", + "waitForText": "partial" + }, + { "op": "cancel" } + ] +} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/replay.override.json b/examples/acp-agent/tests/goal-snapshots/goal-session/replay.override.json new file mode 100644 index 0000000000..b0c5c0f28f --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/replay.override.json @@ -0,0 +1,43 @@ +[ + { + "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 ACP goal-session snapshot proof\",\"max_goal_rounds\":2}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_create", "name": "create_goal", "arguments": "{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}" } }, + { "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" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "GOAL ROUND ONE" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "GOAL ROUND ONE" } }, + { "type": "usage", "usage": { "inputTokens": 40, "outputTokens": 3 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + }, + { "kind": "hang" } +] diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl new file mode 100644 index 0000000000..3129ceead5 --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl @@ -0,0 +1,53 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Create a durable two-round goal for the ACP snapshot, inspect it, then report readiness."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"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 ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}}} +{"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 ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"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 ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}} +{"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 ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"context/message","seq":12,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} +{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"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":"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":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"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":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}} +{"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 ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}} +{"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":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"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":"step/end","seq":31,"time":0,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":32,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":33,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1}}}} +{"type":"user/message","seq":34,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal-session snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1}},"surfaceOp":"append"} +{"type":"step/start","seq":35,"time":0,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"GOAL ROUND ONE"}}} +{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL ROUND ONE"}}}} +{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":41,"time":0,"data":{"turn":2,"step":1,"content":[{"type":"text","text":"GOAL ROUND ONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":40,"outputTokens":3}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"} +{"type":"step/end","seq":42,"time":0,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":43,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":44,"time":0,"data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2}}}} +{"type":"user/message","seq":45,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal-session snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2}},"surfaceOp":"append"} +{"type":"step/start","seq":46,"time":0,"data":{"turn":3,"step":1}} +{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} +{"type":"context/message","seq":49,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} +{"type":"step/end","seq":50,"time":0,"data":{"turn":3,"step":1}} +{"type":"turn/end","seq":51,"time":0,"data":{"turn":3,"reason":{"kind":"aborted","reason":"session/cancel"}}} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl new file mode 100644 index 0000000000..c8da831f95 --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl @@ -0,0 +1 @@ +{"type":"session","version":0,"id":"goal-session-placeholder","createdAt":0,"cwd":"/tmp/goal-session-placeholder"} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl new file mode 100644 index 0000000000..40f60aa7fa --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl @@ -0,0 +1,11 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_goal_create","title":"Create goal","kind":"other","status":"in_progress","rawInput":"Finish the ACP goal-session snapshot proof"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_goal_create","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_goal_get","title":"Read current goal","kind":"read","status":"in_progress"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_goal_get","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL READY"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL ROUND ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} diff --git a/examples/acp-agent/tests/goal.snapshot.ts b/examples/acp-agent/tests/goal.snapshot.ts new file mode 100644 index 0000000000..4bb01acd16 --- /dev/null +++ b/examples/acp-agent/tests/goal.snapshot.ts @@ -0,0 +1,114 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + normalizeSessionLog, + normalizeStdout, + runScenario, + scrubRequestHeaders, + type AgentUnderTest, + type InputScript, + type NormalizeContext, +} from '@deepseek-ai/dsh-acp-snapshot' +import { foldGoal } from '@deepseek-ai/dsh-goal' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { describe, expect, it } from 'vitest' + +// This lifecycle proof has goal-specific timestamp normalization and semantic +// assertions, so it owns a separate snapshot root from the generic ACP suite. +const scenarioDir = join(dirname(fileURLToPath(import.meta.url)), 'goal-snapshots/goal-session') +const fixtureFile = join(scenarioDir, 'session.jsonl') +const overrideFile = join(scenarioDir, 'replay.override.json') +const stdoutExpected = join(scenarioDir, 'stdout.expected.jsonl') +const sessionExpected = join(scenarioDir, 'session.expected.jsonl') +const refreshing = process.env.DSH_SNAPSHOT === 'refresh' + +const agent: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), +} + +interface JsonObject { + [key: string]: unknown +} + +/** Parse non-empty records from one JSONL artifact. */ +function parseJsonl(content: string): JsonObject[] { + return content.split('\n').filter(line => line.trim().length > 0) + .map(line => JSON.parse(line) as JsonObject) +} + +/** Zero durable goal timestamps inside 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 one persisted goal log after the shared snapshot scrubbers. */ +function normalizeGoalLog(content: string, context: NormalizeContext): string { + return parseJsonl(scrubRequestHeaders(normalizeSessionLog(content, context))) + .map(record => JSON.stringify(normalizeGoalTimestamps(record))) + .join('\n') + '\n' +} + +describe('ACP same-session goal snapshot', () => { + it('runs exact automatic rounds in the shipped application and persists cancellation', async () => { + const input = JSON.parse(await readFile(join(scenarioDir, 'input.json'), 'utf8')) as InputScript + const result = await runScenario(input, { + agent, + mode: 'replay', + fixtureFile, + overrideFile, + configPath: agent.configPath, + }) + + expect(result.stderr).toBe('') + expect(result.sessionLogs).toHaveLength(1) + const log = result.sessionLogs[0] + if (log === undefined) throw new Error('goal snapshot did not persist its ACP session') + const records = parseJsonl(log.content) + const events = records.slice(1) as unknown as SessionEvent[] + const calls = events.filter(event => event.type === 'tool/call').map(event => event.data.name) + expect(calls).toEqual(['create_goal', 'get_goal']) + const rounds = events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'goal' + ? [event.data.source.round] + : []) + expect(rounds).toEqual([1, 2]) + expect(foldGoal(events)).toMatchObject({ + goal: { + objective: 'Finish the ACP goal-session snapshot proof', + phase: 'paused', + revision: 2, + maxGoalRounds: 2, + }, + roundsStarted: 2, + }) + + const context: NormalizeContext = { + sessionIds: [result.sessionId, log.id].filter((id): id is string => id !== undefined), + cwd: result.cwd, + } + const stdout = normalizeStdout(result.rawStdout, context) + const session = normalizeGoalLog(log.content, context) + if (refreshing) { + await Promise.all([ + writeFile(stdoutExpected, stdout), + writeFile(sessionExpected, session), + ]) + } + expect(stdout).toBe(await readFile(stdoutExpected, 'utf8')) + expect(session).toBe(await readFile(sessionExpected, 'utf8')) + }) +}) diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl index ce3fe8acef..0525c314cb 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-mount","title":"Mount plugin into live cordis runtime","kind":"execute","status":"in_progress","rawInput":{"code":"return { name: 'snapshot-marker', apply() {} }"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-mount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-code","title":"return await tools.cordis_inspect({ what: 'dynamic' })","kind":"execute","status":"in_progress","rawInput":"return await tools.cordis_inspect({ what: 'dynamic' })"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index a4876185b0..e5773c1319 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -15,11 +15,15 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +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. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + ## Writing code for run_code Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: @@ -67,6 +71,13 @@ declare const tools: { /** The dynamic mount id returned by cordis_mount (e.g. "dyn-1"). */ id: string; }): Promise; + /** 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. */ + create_goal(args: { + /** The concrete completion objective inferred from the direct human request. */ + objective: string; + /** Optional positive safe-integer limit on automatic continuation rounds. */ + max_goal_rounds?: number; + }): Promise; /** Edit an existing UTF-8 text file by replacing literal text. */ edit(args: { /** Path to edit, resolved by the filesystem backend. */ @@ -82,6 +93,15 @@ declare const tools: { /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ justification?: string; }): Promise; + /** 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. */ + get_goal(args: Record): Promise; + /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ + ralph(args: { + /** The immutable completion objective for every fresh Ralph round. */ + objective: string; + /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ + maxRounds?: number; + }): Promise; /** Read a UTF-8 text file and return line-numbered content. */ read(args: { /** Path to read, resolved by the filesystem backend. */ @@ -142,6 +162,21 @@ declare const tools: { status: "pending" | "in_progress" | "completed"; })[]; }): Promise; + /** 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. */ + update_goal(args: { + /** Exact id returned by get_goal. */ + goal_id: string; + /** Exact positive revision returned by get_goal. */ + revision: number; + /** edit | pause | resume | complete | blocked */ + action: "edit" | "pause" | "resume" | "complete" | "blocked"; + /** Replacement objective; valid only with action edit. */ + objective?: string; + /** Replacement cap; valid only with action edit. */ + max_goal_rounds?: number; + /** Concrete blocking condition; required only with action blocked. */ + blocked_reason?: string; + }): Promise; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ workflow(args: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 2a6dc9078d..71597bc9c3 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -102,6 +102,26 @@ ] } }, + { + "name": "create_goal", + "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.", + "parameters": { + "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 limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "edit", "description": "Edit an existing UTF-8 text file by replacing literal text.", @@ -144,6 +164,34 @@ ] } }, + { + "name": "get_goal", + "description": "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.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "read", "description": "Read a UTF-8 text file and return line-numbered content.", @@ -340,6 +388,51 @@ ] } }, + { + "name": "update_goal", + "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 and must explain it in blocked_reason.", + "parameters": { + "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." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", diff --git a/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl index d9d2632bd0..3604bf6eea 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_spill","title":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","kind":"execute","status":"in_progress","rawInput":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","content":[{"type":"content","content":{"type":"text","text":"Print large deterministic output"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: {{spillLocator:bash.txt}}. Use read with offset/limit, or grep this path to search within it.)\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl index 4111ecc8de..369eb1cbfe 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 73d0e5db92..ac1114d0a3 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -15,11 +15,15 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +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. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + ## Writing code for run_code Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: @@ -50,6 +54,13 @@ declare const tools: { /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ justification?: string; }): Promise; + /** 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. */ + create_goal(args: { + /** The concrete completion objective inferred from the direct human request. */ + objective: string; + /** Optional positive safe-integer limit on automatic continuation rounds. */ + max_goal_rounds?: number; + }): Promise; /** Edit an existing UTF-8 text file by replacing literal text. */ edit(args: { /** Path to edit, resolved by the filesystem backend. */ @@ -65,6 +76,15 @@ declare const tools: { /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ justification?: string; }): Promise; + /** 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. */ + get_goal(args: Record): Promise; + /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ + ralph(args: { + /** The immutable completion objective for every fresh Ralph round. */ + objective: string; + /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ + maxRounds?: number; + }): Promise; /** Read a UTF-8 text file and return line-numbered content. */ read(args: { /** Path to read, resolved by the filesystem backend. */ @@ -125,6 +145,21 @@ declare const tools: { status: "pending" | "in_progress" | "completed"; })[]; }): Promise; + /** 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. */ + update_goal(args: { + /** Exact id returned by get_goal. */ + goal_id: string; + /** Exact positive revision returned by get_goal. */ + revision: number; + /** edit | pause | resume | complete | blocked */ + action: "edit" | "pause" | "resume" | "complete" | "blocked"; + /** Replacement objective; valid only with action edit. */ + objective?: string; + /** Replacement cap; valid only with action edit. */ + max_goal_rounds?: number; + /** Concrete blocking condition; required only with action blocked. */ + blocked_reason?: string; + }): Promise; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ workflow(args: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 87ce19b1f6..ab52ec415d 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -45,6 +45,26 @@ ] } }, + { + "name": "create_goal", + "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.", + "parameters": { + "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 limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "edit", "description": "Edit an existing UTF-8 text file by replacing literal text.", @@ -87,6 +107,34 @@ ] } }, + { + "name": "get_goal", + "description": "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.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "read", "description": "Read a UTF-8 text file and return line-numbered content.", @@ -283,6 +331,51 @@ ] } }, + { + "name": "update_goal", + "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 and must explain it in blocked_reason.", + "parameters": { + "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." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl index 11178b9bc7..9cce0b2875 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: command aborted\n```"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl index 4146e8804d..cbc74eb43b 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl index f3bd0b345c..aba7522f8d 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 73d0e5db92..ac1114d0a3 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -15,11 +15,15 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +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. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + ## Writing code for run_code Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: @@ -50,6 +54,13 @@ declare const tools: { /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ justification?: string; }): Promise; + /** 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. */ + create_goal(args: { + /** The concrete completion objective inferred from the direct human request. */ + objective: string; + /** Optional positive safe-integer limit on automatic continuation rounds. */ + max_goal_rounds?: number; + }): Promise; /** Edit an existing UTF-8 text file by replacing literal text. */ edit(args: { /** Path to edit, resolved by the filesystem backend. */ @@ -65,6 +76,15 @@ declare const tools: { /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ justification?: string; }): Promise; + /** 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. */ + get_goal(args: Record): Promise; + /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ + ralph(args: { + /** The immutable completion objective for every fresh Ralph round. */ + objective: string; + /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ + maxRounds?: number; + }): Promise; /** Read a UTF-8 text file and return line-numbered content. */ read(args: { /** Path to read, resolved by the filesystem backend. */ @@ -125,6 +145,21 @@ declare const tools: { status: "pending" | "in_progress" | "completed"; })[]; }): Promise; + /** 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. */ + update_goal(args: { + /** Exact id returned by get_goal. */ + goal_id: string; + /** Exact positive revision returned by get_goal. */ + revision: number; + /** edit | pause | resume | complete | blocked */ + action: "edit" | "pause" | "resume" | "complete" | "blocked"; + /** Replacement objective; valid only with action edit. */ + objective?: string; + /** Replacement cap; valid only with action edit. */ + max_goal_rounds?: number; + /** Concrete blocking condition; required only with action blocked. */ + blocked_reason?: string; + }): Promise; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ workflow(args: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl index 3d25d176ac..823e2753fb 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index 73d0e5db92..ac1114d0a3 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -15,11 +15,15 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +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. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + ## Writing code for run_code Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: @@ -50,6 +54,13 @@ declare const tools: { /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ justification?: string; }): Promise; + /** 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. */ + create_goal(args: { + /** The concrete completion objective inferred from the direct human request. */ + objective: string; + /** Optional positive safe-integer limit on automatic continuation rounds. */ + max_goal_rounds?: number; + }): Promise; /** Edit an existing UTF-8 text file by replacing literal text. */ edit(args: { /** Path to edit, resolved by the filesystem backend. */ @@ -65,6 +76,15 @@ declare const tools: { /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ justification?: string; }): Promise; + /** 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. */ + get_goal(args: Record): Promise; + /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ + ralph(args: { + /** The immutable completion objective for every fresh Ralph round. */ + objective: string; + /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ + maxRounds?: number; + }): Promise; /** Read a UTF-8 text file and return line-numbered content. */ read(args: { /** Path to read, resolved by the filesystem backend. */ @@ -125,6 +145,21 @@ declare const tools: { status: "pending" | "in_progress" | "completed"; })[]; }): Promise; + /** 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. */ + update_goal(args: { + /** Exact id returned by get_goal. */ + goal_id: string; + /** Exact positive revision returned by get_goal. */ + revision: number; + /** edit | pause | resume | complete | blocked */ + action: "edit" | "pause" | "resume" | "complete" | "blocked"; + /** Replacement objective; valid only with action edit. */ + objective?: string; + /** Replacement cap; valid only with action edit. */ + max_goal_rounds?: number; + /** Concrete blocking condition; required only with action blocked. */ + blocked_reason?: string; + }): Promise; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ workflow(args: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ diff --git a/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl index 47dc73536f..eddfde0332 100644 --- a/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Invalid params: unknown permission value \"plan\""}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index e5fd8608d6..5528c956d8 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl index f941121f12..beb74ff218 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n\n[Model attempt failed; any partial output above is discarded: simulated provider error (HTTP 401)]\n\n"}}}} {"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error: turn failed: simulated provider error (HTTP 401)"}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl index 92743b8133..bf1ae4690e 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl index c8a9b320f0..915a9ce790 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl index fab47cc857..7b336458b7 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl index b8b2f245ee..eee73051d8 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl index d92ed5520b..1a2d8dc7c9 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl index 44ce1184e9..36ad25081c 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl index 712e8e5c3b..7e232e6a6e 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl index d06162a005..101c66ef03 100644 --- a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl index 270d1ace7c..45e4cf6f09 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl index 1cac540a29..936605a8a4 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/goal-command-status/input.json b/examples/acp-agent/tests/snapshots/goal-command-status/input.json new file mode 100644 index 0000000000..0bc0192c93 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/goal-command-status/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "/goal" } + ] +} diff --git a/examples/acp-agent/tests/snapshots/goal-command-status/session.jsonl b/examples/acp-agent/tests/snapshots/goal-command-status/session.jsonl new file mode 100644 index 0000000000..a6f73319bc --- /dev/null +++ b/examples/acp-agent/tests/snapshots/goal-command-status/session.jsonl @@ -0,0 +1 @@ +{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} diff --git a/examples/acp-agent/tests/snapshots/goal-command-status/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/goal-command-status/stdout.expected.jsonl new file mode 100644 index 0000000000..2ea5b1c29a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/goal-command-status/stdout.expected.jsonl @@ -0,0 +1,5 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"No goal is currently set.\nUsage: /goal [|clear|edit |pause|resume]"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl index fb4f7cbbc5..e87bb6fec2 100644 --- a/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl @@ -1,2 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl index af42092168..ed1fcffbb6 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl index bed3d3a03a..8610ab9e5d 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl index 48b5df1ac2..a9569efe89 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl index 74f4b9ea10..45d86060e2 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl index 29023a8d45..aa2ff437c6 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl @@ -1,3 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl index 15bf48cb81..1e4ee03de9 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl index 2d92b5b3e7..1ba573f6a7 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl index 7870b73dc2..59cce4a026 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl index cece2795f7..0a83a3ba96 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl index 1a0e83d191..5127561889 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl index 29023a8d45..aa2ff437c6 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl @@ -1,3 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl index 15a91d1af3..77959d4a25 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl index a3e72075ed..cc99b0d674 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl index 291525f825..d8ca0b625f 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md index e89336a2fe..e5f8f35c02 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md @@ -15,11 +15,15 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +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. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + You are an AI agent powered by the DeepSeek Harness SDK. @@ -39,7 +43,11 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +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. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json index 9b64929d83..1bfe74b704 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json @@ -45,6 +45,26 @@ ] } }, + { + "name": "create_goal", + "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.", + "parameters": { + "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 limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "edit", "description": "Edit an existing UTF-8 text file by replacing literal text.", @@ -87,6 +107,34 @@ ] } }, + { + "name": "get_goal", + "description": "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.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "read", "description": "Read a UTF-8 text file and return line-numbered content.", @@ -267,6 +315,51 @@ ] } }, + { + "name": "update_goal", + "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 and must explain it in blocked_reason.", + "parameters": { + "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." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", @@ -419,6 +512,26 @@ ] } }, + { + "name": "create_goal", + "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.", + "parameters": { + "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 limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "edit", "description": "Edit an existing UTF-8 text file by replacing literal text.", @@ -461,6 +574,34 @@ ] } }, + { + "name": "get_goal", + "description": "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.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "read", "description": "Read a UTF-8 text file and return line-numbered content.", @@ -641,6 +782,51 @@ ] } }, + { + "name": "update_goal", + "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 and must explain it in blocked_reason.", + "parameters": { + "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." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", diff --git a/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl index 9a55a86f02..4e19987b4b 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl index 8680db3d30..1168a1c2ba 100644 --- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_a","title":"Read a.txt","kind":"read","status":"in_progress","locations":[{"path":"a.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_b","title":"Read b.txt","kind":"read","status":"in_progress","locations":[{"path":"b.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_a","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl index 358e81f076..66ada00897 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md index 47a68e9a03..3ee1805568 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md @@ -15,10 +15,14 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +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. + Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + You are an AI agent powered by the DeepSeek Harness SDK. @@ -38,7 +42,11 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +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. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json index 9b64929d83..1bfe74b704 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json @@ -45,6 +45,26 @@ ] } }, + { + "name": "create_goal", + "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.", + "parameters": { + "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 limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "edit", "description": "Edit an existing UTF-8 text file by replacing literal text.", @@ -87,6 +107,34 @@ ] } }, + { + "name": "get_goal", + "description": "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.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "read", "description": "Read a UTF-8 text file and return line-numbered content.", @@ -267,6 +315,51 @@ ] } }, + { + "name": "update_goal", + "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 and must explain it in blocked_reason.", + "parameters": { + "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." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", @@ -419,6 +512,26 @@ ] } }, + { + "name": "create_goal", + "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.", + "parameters": { + "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 limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "edit", "description": "Edit an existing UTF-8 text file by replacing literal text.", @@ -461,6 +574,34 @@ ] } }, + { + "name": "get_goal", + "description": "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.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "read", "description": "Read a UTF-8 text file and return line-numbered content.", @@ -641,6 +782,51 @@ ] } }, + { + "name": "update_goal", + "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 and must explain it in blocked_reason.", + "parameters": { + "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." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl index 8469933a94..caa76c02a2 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl index a1b4b8c0cb..ba739a8f26 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Load the requested skill."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skill_load","title":"Load skill snapshot-skill","kind":"read","status":"in_progress","rawInput":"snapshot-skill"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skill_load","status":"completed","content":[{"type":"content","content":{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md index ddf502a773..17e6773a03 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md @@ -15,7 +15,11 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +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. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index 151e76201b..52b3c1812e 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -45,6 +45,26 @@ ] } }, + { + "name": "create_goal", + "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.", + "parameters": { + "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 limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "edit", "description": "Edit an existing UTF-8 text file by replacing literal text.", @@ -87,6 +107,34 @@ ] } }, + { + "name": "get_goal", + "description": "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.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "read", "description": "Read a UTF-8 text file and return line-numbered content.", @@ -267,6 +315,51 @@ ] } }, + { + "name": "update_goal", + "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 and must explain it in blocked_reason.", + "parameters": { + "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." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl index 7f629a2d71..528df252fc 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_root_child","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Start depth one","prompt":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_root_child","status":"completed","content":[{"type":"content","content":{"type":"text","text":"DEPTH_ONE_DONE"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ROOT_DONE"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl index e2941dd851..d8da0f5139 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl index e5cc8bfa90..7326438e3d 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl index bd4fb81d4a..8e9e5fef4d 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl index 2b77e856e6..a55167b920 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl index c717c3182a..e4891bcd99 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md index ddf502a773..17e6773a03 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md @@ -15,7 +15,11 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +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. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index 151e76201b..52b3c1812e 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -45,6 +45,26 @@ ] } }, + { + "name": "create_goal", + "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.", + "parameters": { + "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 limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "edit", "description": "Edit an existing UTF-8 text file by replacing literal text.", @@ -87,6 +107,34 @@ ] } }, + { + "name": "get_goal", + "description": "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.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "read", "description": "Read a UTF-8 text file and return line-numbered content.", @@ -267,6 +315,51 @@ ] } }, + { + "name": "update_goal", + "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 and must explain it in blocked_reason.", + "parameters": { + "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." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", diff --git a/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl index 8771e50182..dc2d2db798 100644 --- a/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl index 2c19d8feb9..5acf70d5d8 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl index 03f482bcc6..5421647044 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl index a55c6d6e01..225d94ff16 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_workspace_read","title":"Read nested/task.txt","kind":"read","status":"in_progress","locations":[{"path":"nested/task.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_workspace_read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md index b1fc71924b..6cd8d5725f 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md @@ -15,7 +15,11 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +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. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index 151e76201b..52b3c1812e 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -45,6 +45,26 @@ ] } }, + { + "name": "create_goal", + "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.", + "parameters": { + "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 limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "edit", "description": "Edit an existing UTF-8 text file by replacing literal text.", @@ -87,6 +107,34 @@ ] } }, + { + "name": "get_goal", + "description": "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.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "read", "description": "Read a UTF-8 text file and return line-numbered content.", @@ -267,6 +315,51 @@ ] } }, + { + "name": "update_goal", + "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 and must explain it in blocked_reason.", + "parameters": { + "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." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl index 4e8db74ba6..f588960720 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index 79cc348803..a1e2455ed3 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -1,6 +1,6 @@ # headless-agent -Headless one-shot agent wiring: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows + `todo_write` + JSONL persistence, with [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) as the app front door. +Headless one-shot agent wiring: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + JSONL persistence, with [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) as the app front door. ## Run it diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 9533af28d3..3e5876c2b6 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -37,6 +37,8 @@ flowchart LR cfg --> plugin_headless_workflow_workerthread plugin_headless_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] cfg --> plugin_headless_tool_workflow + plugin_headless_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] + cfg --> plugin_headless_tool_ralph plugin_headless_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] cfg --> plugin_headless_tool_todo plugin_headless_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] @@ -60,6 +62,7 @@ flowchart LR | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | +| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | | `tool-todo` | `@deepseek-ai/dsh-tool-todo` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 9edf6685fb..aa2d9fd7ca 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -84,6 +84,11 @@ - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' +# A separate fixed consumer demonstrates fresh-agent Ralph iteration without +# changing the workflow tool or same-session goal behavior. +- id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + # `todo_write` replaces the logged whole list. - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' diff --git a/examples/headless-agent/goal.cordis.snapshot.yml b/examples/headless-agent/goal.cordis.snapshot.yml new file mode 100644 index 0000000000..b853410ef0 --- /dev/null +++ b/examples/headless-agent/goal.cordis.snapshot.yml @@ -0,0 +1,12 @@ +# 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..8f8cdf9e0b --- /dev/null +++ b/examples/headless-agent/goal.cordis.yml @@ -0,0 +1,11 @@ +# 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/ralph.cordis.snapshot.yml b/examples/headless-agent/ralph.cordis.snapshot.yml new file mode 100644 index 0000000000..e84bdfed31 --- /dev/null +++ b/examples/headless-agent/ralph.cordis.snapshot.yml @@ -0,0 +1,12 @@ +# Replay counterpart to cordis.yml for the shipped Ralph-loop snapshot. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./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/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index dc448b9b23..140f9038b3 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -11,10 +11,14 @@ 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 ralphScenarioDir = join(snapshotsDir, 'ralph-loop') +const ralphConfigPath = fileURLToPath(new URL('../ralph.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 +74,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 +118,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 }, @@ -132,6 +163,131 @@ 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) + + it('replays two fresh Ralph rounds through the one-shot app', async () => { + const prompt = await scenarioPrompt(ralphScenarioDir, 'ralph-loop') + const streamExpected = join(ralphScenarioDir, 'stream-json.expected.jsonl') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'Ralph loop headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-ralph-loop-', + binScript, + configPath: ralphConfigPath, + binArgs: ['--config', ralphConfigPath, '--output-format', 'stream-json', prompt], + tsconfigPath, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: join(ralphScenarioDir, 'session.jsonl'), + DSH_SNAPSHOT_OVERRIDE: join(ralphScenarioDir, 'replay.override.json'), + DSH_SNAPSHOT_CHILD_FILES: [ + join(ralphScenarioDir, 'session.1.jsonl'), + join(ralphScenarioDir, 'session.2.jsonl'), + ].join(delimiter), + 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(3) + const parent = logs.find(log => typeof log.header.parentSession !== 'string') + if (parent === undefined) throw new Error('Ralph snapshot did not persist its parent session') + const parentId = parent.header.id + expect(typeof parentId).toBe('string') + const children = logs.filter(log => typeof log.header.parentSession === 'string') + .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt)) + expect(children).toHaveLength(2) + expect(children.map(child => child.header.parentSession)).toEqual([parentId, parentId]) + expect(children.map(child => child.header.cwd)).toEqual([parent.header.cwd, parent.header.cwd]) + expect(parent.header.delegationDepth).toBe(0) + expect(children.map(child => child.header.delegationDepth)).toEqual([1, 1]) + expect(children.map(child => child.header.seedLength)).toEqual([undefined, undefined]) + expect(new Set(children.map(child => child.header.id)).size).toBe(2) + + const parentRecords = parseJsonl(parent.content) + const parentCalls = parentRecords.filter(record => record.type === 'tool/call') + expect(parentCalls.map(record => (record.data as JsonObject | undefined)?.name)).toEqual(['ralph']) + const parentResult = parentRecords.find(record => record.type === 'tool/result') + const parentResultData = parentResult?.data as JsonObject | undefined + expect(parentResultData?.isError).toBe(false) + expect(JSON.stringify(parentResultData?.content)).toContain('reported completion after 2 rounds') + + const childRecords = children.map(child => parseJsonl(child.content)) + const childPrompts = childRecords.map((records) => { + const message = records.find(record => record.type === 'user/message') + return JSON.stringify((message?.data as JsonObject | undefined)?.content) + }) + expect(childPrompts[0]).toContain('Ralph round: 1 of 2.') + expect(childPrompts[0]).toContain('(none — this is the first round)') + expect(childPrompts[0]).not.toContain('ROUND_ONE_HANDOFF') + expect(childPrompts[1]).toContain('Ralph round: 2 of 2.') + expect(childPrompts[1]).toContain('ROUND_ONE_HANDOFF') + for (const childPrompt of childPrompts) { + expect(childPrompt).toContain('Prove two fresh Ralph rounds through the shipped headless app.') + expect(childPrompt).not.toContain('Run a two-round fresh-agent Ralph loop') + } + for (const records of childRecords) { + const calls = records.filter(record => record.type === 'tool/call') + expect(calls.map(record => (record.data as JsonObject | undefined)?.name)) + .toEqual(['structured_output']) + } + }, + }) + expect(result.stderr).toBe('') const normalized = normalizeHeadlessStream(result.stdout, runCwd) if (refreshing) await writeFile(streamExpected, normalized) 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..5263ccd4e2 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/goal-tools/input.json @@ -0,0 +1,8 @@ +{ + "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..aec5204c7d --- /dev/null +++ b/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json @@ -0,0 +1,32 @@ +[ + { + "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..0721003577 --- /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},"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/examples/headless-agent/tests/snapshots/ralph-loop/input.json b/examples/headless-agent/tests/snapshots/ralph-loop/input.json new file mode 100644 index 0000000000..42652a4ac5 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/ralph-loop/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { + "op": "prompt", + "text": "Run a two-round fresh-agent Ralph loop to prove the shipped headless integration." + } + ] +} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/replay.override.json b/examples/headless-agent/tests/snapshots/ralph-loop/replay.override.json new file mode 100644 index 0000000000..1d7846f76b --- /dev/null +++ b/examples/headless-agent/tests/snapshots/ralph-loop/replay.override.json @@ -0,0 +1,22 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_ralph", "name": "ralph", "argumentsDelta": "{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_ralph", "name": "ralph", "arguments": "{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}" } }, + { "type": "usage", "usage": { "inputTokens": 20, "outputTokens": 8 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "RALPH SNAPSHOT COMPLETE" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "RALPH SNAPSHOT COMPLETE" } }, + { "type": "usage", "usage": { "inputTokens": 30, "outputTokens": 4 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl new file mode 100644 index 0000000000..ca36330c36 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl @@ -0,0 +1,6 @@ +{"type":"session","version":0,"id":"42222222-2222-4222-8222-222222222222","createdAt":1783951001000,"cwd":"/tmp/ralph-headless","parentSession":"41111111-1111-4111-8111-111111111111"} +{"type":"assistant/chunk","seq":0,"time":1783951001001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":1,"time":1783951001002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"round-one-report","name":"structured_output","argumentsDelta":"{\"status\":\"continue\",\"summary\":\"ROUND_ONE_HANDOFF\",\"evidence\":[\"Round one inspected the workspace.\"],\"nextSteps\":[\"Finish the snapshot objective.\"],\"blocker\":\"\"}"}}} +{"type":"assistant/chunk","seq":2,"time":1783951001003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"round-one-report","name":"structured_output","arguments":"{\"status\":\"continue\",\"summary\":\"ROUND_ONE_HANDOFF\",\"evidence\":[\"Round one inspected the workspace.\"],\"nextSteps\":[\"Finish the snapshot objective.\"],\"blocker\":\"\"}"}}}} +{"type":"assistant/chunk","seq":3,"time":1783951001004,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":12}}}} +{"type":"assistant/chunk","seq":4,"time":1783951001005,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl new file mode 100644 index 0000000000..c722158098 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl @@ -0,0 +1,6 @@ +{"type":"session","version":0,"id":"43333333-3333-4333-8333-333333333333","createdAt":1783951002000,"cwd":"/tmp/ralph-headless","parentSession":"41111111-1111-4111-8111-111111111111"} +{"type":"assistant/chunk","seq":0,"time":1783951002001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":1,"time":1783951002002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"round-two-report","name":"structured_output","argumentsDelta":"{\"status\":\"complete\",\"summary\":\"The Ralph snapshot objective is complete.\",\"evidence\":[\"Two fresh rounds completed through the shipped app.\"],\"nextSteps\":[],\"blocker\":\"\"}"}}} +{"type":"assistant/chunk","seq":2,"time":1783951002003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"round-two-report","name":"structured_output","arguments":"{\"status\":\"complete\",\"summary\":\"The Ralph snapshot objective is complete.\",\"evidence\":[\"Two fresh rounds completed through the shipped app.\"],\"nextSteps\":[],\"blocker\":\"\"}"}}}} +{"type":"assistant/chunk","seq":3,"time":1783951002004,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":12}}}} +{"type":"assistant/chunk","seq":4,"time":1783951002005,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl new file mode 100644 index 0000000000..c452fb5fa8 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl @@ -0,0 +1 @@ +{"type":"session","version":0,"id":"41111111-1111-4111-8111-111111111111","createdAt":1783951000000,"cwd":"/tmp/ralph-headless"} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl new file mode 100644 index 0000000000..727f84fd90 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl @@ -0,0 +1,23 @@ +{"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":"Run a two-round fresh-agent Ralph loop to prove the shipped headless integration."}],"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_ralph","name":"ralph","argumentsDelta":"{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}"}}}} +{"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_ralph","name":"ralph","arguments":"{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}"}}}}} +{"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_ralph","name":"ralph","arguments":"{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}"}],"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_ralph","name":"ralph","arguments":"{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_ralph","content":[{"type":"text","text":"Ralph worker reported completion after 2 rounds.\nFinal report:\n{\n \"status\": \"complete\",\n \"summary\": \"The Ralph snapshot objective is complete.\",\n \"evidence\": [\n \"Two fresh rounds completed through the shipped app.\"\n ],\n \"nextSteps\": [],\n \"blocker\": \"\"\n}"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"RALPH SNAPSHOT COMPLETE"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RALPH SNAPSHOT COMPLETE"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"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":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"RALPH SNAPSHOT COMPLETE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":21,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"RALPH SNAPSHOT COMPLETE","reason":{"kind":"completed"},"usage":{"inputTokens":50,"outputTokens":12}} diff --git a/examples/package.json b/examples/package.json index 37ee2fad33..8207c88ea3 100644 --- a/examples/package.json +++ b/examples/package.json @@ -18,8 +18,9 @@ "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-policy": "workspace:*", - "@deepseek-ai/dsh-goal": "workspace:*", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:*", + "@deepseek-ai/dsh-goal-session": "workspace:*", "@deepseek-ai/dsh-hooks-claude": "workspace:*", "@deepseek-ai/dsh-hooks-codex": "workspace:*", "@deepseek-ai/dsh-jsonrpc": "workspace:*", @@ -43,6 +44,8 @@ "@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-ralph": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index 4af7be198f..8cd95e896b 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -1,6 +1,6 @@ # tui-agent -The full-screen interactive coding agent: DeepSeek V4, local bash and filesystem tools, compaction, subagents, workflows, `todo_write`, timeout/spill policy, and [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo). +The full-screen interactive coding agent: DeepSeek V4, local bash and filesystem tools, compaction, subagents, workflows and fresh-agent Ralph iteration, `todo_write`, timeout/spill policy, and [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo). ## Run it diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index bed564ae34..249d2f6aaf 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -43,6 +43,8 @@ flowchart LR cfg --> plugin_tui_workflow_workerthread plugin_tui_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] cfg --> plugin_tui_tool_workflow + plugin_tui_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] + cfg --> plugin_tui_tool_ralph plugin_tui_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] cfg --> plugin_tui_tool_todo plugin_tui_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] @@ -77,6 +79,7 @@ flowchart LR | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | +| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | | `tool-todo` | `@deepseek-ai/dsh-tool-todo` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 73b42b284a..7426416204 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -80,6 +80,11 @@ - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' +# A separate fixed consumer demonstrates fresh-agent Ralph iteration without +# changing the workflow tool or same-session goal behavior. +- id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 534207c875..a9f937f883 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -9,6 +9,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' +import CommandService from '@deepseek-ai/dsh-commands' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' @@ -21,6 +22,7 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' +import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' import { createTuiChat } from '@deepseek-ai/dsh-tui' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -179,6 +181,8 @@ async function mountScenarioContext( await ctx.plugin(ToolSubagent, { provider: 'spawn', toolName: 'subagent', enableRunInBackground: false }) await ctx.plugin(WorkerWorkflowEngine, { provider: 'spawn' }) await ctx.plugin(ToolWorkflow) + await ctx.plugin(ToolRalph) + await ctx.plugin(CommandService) if (scenario.composition === 'code' || scenario.composition === 'advanced') { await ctx.plugin(WorkerCodeRuntime, {}) } diff --git a/knip.json b/knip.json index aa9dc24bd2..96bcb99e4d 100644 --- a/knip.json +++ b/knip.json @@ -77,6 +77,14 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/goal/goal-session": { + "entry": ["tests/**/*.spec.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/goal/tool-goal": { + "entry": ["tests/**/*.spec.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/code-runtime/code-runtime-worker": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] @@ -121,6 +129,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/ui/commands": { + "entry": ["tests/**/*.spec.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/examples/tui-demo": { "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/README.md b/packages/README.md index 3976a0da09..c491ab2cc3 100644 --- a/packages/README.md +++ b/packages/README.md @@ -20,7 +20,7 @@ Packages live at `packages///`; groups are containers, while names r | [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | | [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface | -| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, the worker-thread engine, and the model-facing `workflow` tool | Product — stable surface | +| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, worker-thread engine, and model-facing `workflow` and fresh-agent `ralph` tools | Product — stable surface | | [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | | [`spill/`](spill/README.md) | Spill capability family: the storage seam, a local impl, and the tool-result spill policy | Product — stable surface | | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 48226657b4..01cc72c0ea 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -198,6 +198,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'commands', + summary: 'Human-command registry.', + methods: [ + { + signature: 'register(definition: CommandDefinition): () => void', + jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */', + }, + { + signature: 'list(agent: Agent): readonly CommandDescriptor[]', + jsDoc: '/**\n * List the effective immutable command descriptors for one agent.\n * @param agent - exact receiving agent and scoped-layer key.\n * @returns name-sorted descriptors after scoped shadowing.\n */', + }, + { + signature: 'find(agent: Agent, name: string): CommandDefinition | undefined', + jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition.\n */', + }, + { + signature: 'async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise', + jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n * @param agent - exact receiving agent.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns a detached result, or `undefined` when syntax or name does not resolve.\n */', + }, + ], + }, { key: 'compact', summary: 'Abstract compaction service.', @@ -258,6 +280,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'get(agent: Agent): GoalView | undefined', jsDoc: '/**\n * Read the current goal for one exact live agent.\n * @param agent - owning live agent.\n * @returns a fresh view or `undefined` when no goal is current.\n * @throws {@link GoalError} when the agent is not the registry\'s live instance.\n */', }, + { + signature: 'disarm(agent: Agent): GoalView | undefined', + jsDoc: '/**\n * Remove process-local continuation authority without changing durable goal\n * phase or revision. Lifecycle owners use this before unloading a driver;\n * a later human-authorized {@link resume} records the new activation edge.\n * @param agent - owning live agent.\n * @returns a fresh disarmed view, or `undefined` when no goal is current.\n */', + }, { signature: 'create(agent: Agent, request: CreateGoalRequest): GoalView', jsDoc: '/**\n * Create and arm a goal. A completed goal may be replaced; every other\n * current phase must be cleared or resumed instead.\n * @param agent - owning live agent.\n * @param request - objective and optional round cap.\n * @returns the created live view.\n */', @@ -684,6 +710,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param sessionId - exact shared agent/session identity that failed startup.\n * @param error - persistence, setup, or publication failure.\n * @mode emit\n */', summary: 'A declarative agent entry failed before it could publish a live agent.', }, + { + name: 'agent/cancel-requested', + mode: 'emit', + signature: '\'agent/cancel-requested\'(this: Scoped, agent: Agent, reason: string): void', + jsDoc: '/**\n * Effective broad cancellation was requested, before queued/steering work\n * is cleared or the active step is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param reason - resolved cancellation reason, including the default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'Effective broad cancellation was requested, before queued/steering work is cleared or the active step is aborted.', + }, { name: 'agent/created', mode: 'emit', @@ -796,6 +829,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Ask composed answerers for one decision. Return an outcome to claim the\n * request or call `next()`; failure yields the fail-closed default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param req - the pending decision (agent, tool identity, reason, signal).\n * @mode waterfall\n */', summary: 'Ask composed answerers for one decision.', }, + { + name: 'commands/change', + mode: 'emit', + signature: '\'commands/change\'(): void', + jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */', + summary: 'A command was registered or unregistered.', + }, { name: 'fs/edit-intent', mode: 'waterfall', @@ -1118,6 +1158,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CollectedOutput', declaration: 'export interface CollectedOutput {\n text: string;\n truncated: boolean;\n spillPath?: string;\n}', }, + { + name: 'CommandDefinition', + declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', + }, + { + name: 'CommandDescriptor', + declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n}', + }, + { + name: 'CommandInputDescriptor', + declaration: 'export interface CommandInputDescriptor {\n readonly hint: string;\n}', + }, + { + name: 'CommandInvocation', + declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}', + }, + { + name: 'CommandResult', + declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};', + }, { name: 'CompactAgentContext', declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}', @@ -1768,7 +1828,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'WorkflowStartRequest', - declaration: 'export interface WorkflowStartRequest {\n script: string;\n meta: WorkflowMeta;\n args?: unknown;\n parent: Agent;\n signal?: AbortSignal;\n}', + declaration: 'export interface WorkflowStartRequest {\n script: string;\n meta: WorkflowMeta;\n args?: unknown;\n subagentProvider?: string;\n maxTotalAgents?: number;\n parent: Agent;\n signal?: AbortSignal;\n}', }, { name: 'WorkflowStopReason', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index e2bdd34d03..1197a018be 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -60,7 +60,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history. -Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. Cancellation clears pending work and aborts the current step without leaking to the next prompt; undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush. +Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. Effective cancellation resolves its reason and emits `agent/cancel-requested` before clearing pending work or aborting the current step; notification failures are contained, queued work added by an observer is included in the same broad clear, and idle cancellation emits nothing. Undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path. diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 8478ec1818..847a15d64f 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -331,13 +331,18 @@ export class ReactLoopAgent implements Agent { } cancel(reason?: string): void { + const resolvedReason = reason ?? 'cancelled' // Arm only for current work; an idle marker would cancel the next prompt. if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) { this.cancelRequested = true // Capture the resolved reason for the marker-only windows (pre-step / // continuation). The mid-step path reads it from abort.signal.reason // below; the marker path reads it via the LoopHandle's cancelReason(). - this.cancelReason = reason ?? 'cancelled' + this.cancelReason = resolvedReason + // Coordination consumers must update their own state before this call + // clears the inbox or aborts the step. Notification failures are + // contained by the fused dispatcher and cannot veto cancellation. + agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedReason) } // Drop all pending queued + steering work (un-started prompts never run; the // cancelled turn's steering is not re-enqueued). Cleared directly even when @@ -347,7 +352,7 @@ export class ReactLoopAgent implements Agent { // Interrupt an in-flight step immediately (the running turn observes the // abort and ends `aborted`). The marker covers the windows where no step is // running (pre-step, continuation). - this.currentAbort?.abort(reason ?? 'cancelled') + this.currentAbort?.abort(resolvedReason) } /** diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 39289779be..f6df171365 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -7,7 +7,7 @@ * @module dsh-agent-loop/tests/cancel */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' @@ -55,6 +55,33 @@ function userTexts(agent: Agent): string[] { } describe('Agent.cancel()', () => { + it('notifies every observer before clearing work and contains listener failures', async () => { + const adapter = new MockAdapter([textResponse('must remain unused')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('cancel-event'), { provider: 'mock', model: 'mock' }) + const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const seen: string[] = [] + ctx.on('agent/cancel-requested', (subject, reason) => { + if (subject !== agent) return + seen.push(`first:${reason}`) + subject.send([{ type: 'text', text: 'queued by cancel observer' }]) + throw new Error('observer failed') + }) + ctx.on('agent/cancel-requested', (subject, reason) => { + if (subject === agent) seen.push(`second:${reason}`) + }) + + send(agent, 'drop me') + agent.cancel() + await new Promise(resolve => setTimeout(resolve, 30)) + agent.cancel('idle no-op') + + expect(seen).toEqual(['first:cancelled', 'second:cancelled']) + expect(userTexts(agent)).toEqual([]) + expect(adapter.requests).toHaveLength(0) + expect(warned).toHaveBeenCalledWith(expect.stringContaining('agent/cancel-requested')) + }) + it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index e9bf1eb2c9..6900faddb0 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -533,7 +533,7 @@ describe('steering from late extension points is never stranded', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing') }) - it('steer() from a step/end session-event listener forces a SAME-TURN next step (/goal pattern)', async () => { + it('steer() from a step/end session-event listener forces a SAME-TURN next step', async () => { // Assert the same-turn shape; content alone cannot distinguish re-enqueue. const adapter = new MockAdapter([ textResponse('no tools, would stop'), diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 1a684aad1c..c5fedd947d 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -46,7 +46,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). +Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved reason, then clears queues and aborts; notification failures are contained and cannot veto the stop. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). `PromptDecision.additionalContexts` is an array so every injected context keeps its own source and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source. @@ -56,10 +56,10 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. 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). The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the rationale. +- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. 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). The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the rationale. - `agent.steer(content, options?)` — submit steering while the agent is `running`. An open turn records it at the next steering checkpoint before a request or continuation decision; policy can still stop before another step. After turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. The method uses the same synchronous snapshot-and-validation boundary as `send` and delegates to `send` when idle - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `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. +- `agent.cancel(reason?)` — cancel ALL pending work: an effective call emits `agent/cancel-requested` before it clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window). Observers may synchronize their own state but cannot veto cancellation. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op with no notification. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index ffeb92e3ba..e0ba54a60d 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 } @@ -122,10 +125,10 @@ export interface Agent { /** * Clear all queued and steering work, including items waiting to start, and - * abort the active step. The supplied reason is preserved across pre-step - * and active cancellation windows, and `whenIdle()` resolves after - * cancellation reaches quiescence. Idle cancellation is a no-op and does not - * arm a later cancel. + * abort the active step. An effective call first emits `agent/cancel-requested` + * with the resolved reason. That reason is preserved across pre-step and active + * cancellation windows, and `whenIdle()` resolves after cancellation reaches + * quiescence. Idle cancellation is a no-op and does not arm a later cancel. */ cancel(reason?: string): void @@ -176,6 +179,16 @@ declare module 'cordis' { * @mode emit */ 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void + /** + * Effective broad cancellation was requested, before queued/steering work + * is cleared or the active step is aborted. This observe-only notification + * cannot veto cancellation; listener failures are contained. + * @param agent - the agent whose current work is being cancelled. + * @param reason - resolved cancellation reason, including the default. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/cancel-requested'(this: Scoped, agent: Agent, reason: string): void // ---- session lifecycle (emit) ---- /** diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index 3cfa0c99a3..5988b58145 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -8,6 +8,7 @@ type ScopedSubjectResolver = (args: readonly unknown[]) => unknown const scopedSubjectResolvers: Readonly> = Object.freeze({ + 'agent/cancel-requested': args => args[0], 'agent/created': args => args[0], 'agent/disposed': args => args[0], 'agent/error': args => args[0], diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 5aa4b6eb61..aaceb99fce 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', 'ralph', '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/examples/README.md b/packages/examples/README.md index f13d15a8ea..c24f1e9173 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -4,10 +4,10 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | Package | npm name | Role | |---|---|---| -| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) | -| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` | +| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with an opt-in persisted-goal stack | +| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` | | `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output | -| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` | +| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + persisted goals + `/goal` command + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` | | `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client | `agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 92d75dec99..38c2b7852d 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -11,6 +11,8 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it | Plugin | Why | |---|---| | `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) | +| `@deepseek-ai/dsh-commands` | the human-command registry used for ACP discovery and direct slash dispatch | +| `@deepseek-ai/dsh-command-goal` | the discoverable direct `/goal` producer; the app enables the spine's persisted-goal stack with it | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) | | `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool | @@ -36,6 +38,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron | `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` | | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | | `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | +| `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer | | `llmRetry` | owner defaults | bounded transient model-request retry policy routed through `dsh-agent-spine-demo` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | @@ -56,7 +59,7 @@ All diagnostics go to **stderr** — stdout is the protocol. ## Model Experience -Indirectly, through `dsh-agent-spine-demo` and `dsh-acp`, which compose each ACP agent's prompt, tools, and message history; this app bundle adds no model-bound content itself. +Indirectly, through `dsh-agent-spine-demo` and `dsh-acp`, which compose each ACP agent's prompt, goal tools, and message history. Direct `/goal` input and output remain outside the model, while accepted mutations append domain-owned model-visible snapshots. #### KV Cache effect diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 6743c55a3e..370dd39fbf 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-acp-demo", - "description": "ACP server app: the agent-spine-demo bundle + JSONL persistence + the ACP bridge (no stdout logger, no hmr, no pre-created agents), with a bin to boot a leaf cordis.yml over JSON-RPC stdio", + "description": "ACP server app: agent spine + human commands + JSONL persistence + ACP bridge (no stdout logger, hmr, or pre-created agents), with a JSON-RPC stdio bin", "version": "0.0.1", "private": true, "type": "module", @@ -38,6 +38,8 @@ "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-acp": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", + "@deepseek-ai/dsh-command-goal": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", @@ -52,6 +54,8 @@ "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 98ba012829..7a6a893c43 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -1,7 +1,7 @@ /** * The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}), - * JSONL session persistence, and the {@link @deepseek-ai/dsh-acp} bridge. It - * writes nothing to stdout. + * human-command registry, JSONL session persistence, and the + * {@link @deepseek-ai/dsh-acp} bridge. It writes nothing to stdout. * It pre-creates no agents and leaves adapters, executors, and optional tools to * the leaf, which must likewise avoid stdout loggers. Named exports are * required so Loader retains this plugin's `Config` schema (see @@ -12,6 +12,8 @@ import type { Context } from 'cordis' import z from 'schemastery' import * as acp from '@deepseek-ai/dsh-acp' +import CommandService from '@deepseek-ai/dsh-commands' +import * as commandGoal from '@deepseek-ai/dsh-command-goal' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' @@ -60,6 +62,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable + /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ + goals?: agentCore.GoalConfig | false /** Bounded transient model-request retry policy forwarded through agent-core. */ llmRetry?: NonNullable } @@ -84,6 +88,7 @@ export const Config: z = z.object({ skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), + goals: z.union([z.const(false), agentCore.GoalConfigSchema]), llmRetry: agentCore.LlmRetryConfigSchema, }) /* jscpd:ignore-end */ @@ -96,7 +101,10 @@ export const Config: z = z.object({ * from the provider/model pair. No logger, no `hmr` — stdout stays pure. */ export function apply(ctx: Context, config: Config): void { - ctx.plugin(agentCore, agentCore.pickSpineConfig(config)) + const goals = config.goals ?? {} + ctx.plugin(CommandService) + if (goals !== false) ctx.plugin(commandGoal) + ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }) ctx.plugin(UserInteractionService) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT, diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 4674c32c4e..01053a049a 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -93,11 +93,30 @@ describe('dsh-acp-demo composition', () => { expect(ctx.get('agentLoop')).toBeDefined() expect(ctx.get('userInteraction')).toBeDefined() expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined() + expect(ctx.get('goals')).toBeDefined() + expect(ctx.get('tools')?.get('get_goal')).toBeDefined() // No pre-created agents — ACP session/new creates them on demand. expect(ctx.get('agents')!.list()).toHaveLength(0) await ctx.fiber.dispose() }) + it('can explicitly omit the persisted-goal stack and its command', async () => { + const ctx = await mount({ + provider: 'mock', + model: 'mock', + goals: false, + workspaceContext: false, + }) + expect(ctx.get('goals')).toBeUndefined() + const handle = await ctx.agents.create({ + sessionId: 'disabled-goals' as import('@deepseek-ai/dsh-session').SessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined() + await handle.dispose() + await ctx.fiber.dispose() + }) + it('defaults the persistence root when omitted', async () => { // Exercises the `DEFAULT_PERSISTENCE_ROOT` fallback for a direct-apply caller that // bypasses the schema's `.default(...)`: call `apply` directly (not via @@ -195,7 +214,17 @@ describe('dsh-acp-demo composition', () => { }) } const assembly = await ctx.get('systemPrompt')!.assemble() - expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill', 'task_kill', 'task_list', 'task_output']) + expect(assembly.tools.map(tool => tool.name)).toEqual([ + 'zulu', + 'alpha', + 'create_goal', + 'get_goal', + 'skill', + 'task_kill', + 'task_list', + 'task_output', + 'update_goal', + ]) await ctx.fiber.dispose() }) diff --git a/packages/examples/acp-demo/tsconfig.json b/packages/examples/acp-demo/tsconfig.json index 2f6acd4a17..5bd1627345 100644 --- a/packages/examples/acp-demo/tsconfig.json +++ b/packages/examples/acp-demo/tsconfig.json @@ -23,6 +23,12 @@ { "path": "../../ui/acp" }, + { + "path": "../../ui/commands" + }, + { + "path": "../../goal/command-goal" + }, { "path": "../../core/agent" }, diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 4ab8b4aded..f7bc0a98c8 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -17,6 +17,9 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-skill skill provider registry @deepseek-ai/dsh-skill-local local filesystem skill provider @deepseek-ai/dsh-agent agent registry + initiator scope + agent/* events +@deepseek-ai/dsh-goal optional persisted same-session goal domain +@deepseek-ai/dsh-tool-goal optional model-facing goal controls +@deepseek-ai/dsh-goal-session optional same-session goal-round driver @deepseek-ai/dsh-llm-retry bounded transient request retry policy @deepseek-ai/dsh-tasks generic background-task registry @deepseek-ai/dsh-invariants configurable invariant registry service @@ -48,11 +51,11 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, invariants?, llmRetry? } +// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants?, llmRetry? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. @@ -64,7 +67,7 @@ The bounded retry policy may repeat a transiently failed request in a new number ## Model Experience -Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, `dsh-tools`, and `dsh-llm-retry`, which this bundle mounts without adding model-bound wrapper content. +Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, `dsh-tools`, and `dsh-llm-retry`, plus `dsh-tool-goal` and goal-round prompts when `goals` is enabled. The bundle adds no model-bound wrapper content of its own. #### KV Cache effect @@ -72,5 +75,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit the bundled skills and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle. +- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit bundled goals, skills, and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle. - **The invariant seam and companions remain fixed members** — `invariants.enabled: false` or package filters suppress checks but do not remove the service or companion registrations; Session's always-on validation and freezing are separate. diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index bbc8874d3f..b39e0fd178 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-spine-demo", - "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + bounded retry + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)", + "description": "The default executor-less/UI-less agent spine with bounded retry and optional persisted goals", "version": "0.0.1", "private": true, "type": "module", @@ -30,6 +30,8 @@ "@cordisjs/plugin-timer": "^1.1.2", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", + "@deepseek-ai/dsh-goal": "^0.0.1", + "@deepseek-ai/dsh-goal-session": "^0.0.1", "@deepseek-ai/dsh-home": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", @@ -41,6 +43,7 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tool-bash": "^0.0.1", + "@deepseek-ai/dsh-tool-goal": "^0.0.1", "@deepseek-ai/dsh-tool-skill": "^0.0.1", "@deepseek-ai/dsh-tool-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -51,6 +54,8 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -63,6 +68,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-goal": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 18ebeb7ab9..049bdc9076 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -1,6 +1,6 @@ /** * Default executor-less, UI-less agent spine. It bundles the common services, - * background-task registry and controls, concrete loop, local skill and + * background-task registry and controls, optional persisted goals, concrete loop, local skill and * workspace-context providers, and model-facing bash/skill consumers; * deployments still choose the LLM adapter, bash executor, and presentation. * The plugin intentionally exposes named exports only because Loader default @@ -18,6 +18,9 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import AgentRegistry from '@deepseek-ai/dsh-agent' +import GoalService, { type Config as GoalDomainConfig } from '@deepseek-ai/dsh-goal' +import * as goalSession from '@deepseek-ai/dsh-goal-session' +import * as toolGoal from '@deepseek-ai/dsh-tool-goal' import TaskService from '@deepseek-ai/dsh-tasks' import InvariantService, { type Config as InvariantConfig } from '@deepseek-ai/dsh-invariants' import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant' @@ -46,6 +49,14 @@ export interface SkillConfig { tool?: toolSkill.Config } +/** Persisted goal domain, model-tool policy, and same-session driver config. */ +export interface GoalConfig { + /** Goal-domain creation defaults. */ + domain?: GoalDomainConfig + /** Model-facing goal-tool authority policy. */ + tool?: toolGoal.Config +} + /** * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP @@ -54,9 +65,11 @@ export interface SkillConfig { * order), the `tools` object to the tool registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the - * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. `llmRetry` configures bounded request recovery; - * `invariants` configures global and package-filtered relational checks. Owner schemas supply defaults for optional input; + * workspace-context loader, `llmRetry` to the bounded request-recovery policy, + * and `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * `goals` opts into and configures the persisted goal domain plus its model tool + * and same-session driver; `invariants` configures global and package-filtered + * relational checks. Owner schemas supply defaults for optional input; * workspace context instead requires an explicit byte budget or `false` because * it changes model-visible input. Producer opt-in stays producer-local: * `toolBash` configures bash only; independently composed producers keep their @@ -85,6 +98,8 @@ export interface Config { toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ invariants?: InvariantConfig + /** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */ + goals?: GoalConfig | false /** Bounded transient model-request retry policy. */ llmRetry?: llmRetry.Config } @@ -103,6 +118,12 @@ export const ToolBashConfigSchema: z = toolBash.Config /** The task-control-tool config schema exported for app packages that forward `toolTasks`. */ export const ToolTasksConfigSchema: z = toolTasks.Config +/** The persisted-goal config schema exported for app packages that opt in. */ +export const GoalConfigSchema: z = z.object({ + domain: GoalService.Config, + tool: toolGoal.Config, +}) + /** The bounded LLM retry schema exported for app packages that forward `llmRetry`. */ export const LlmRetryConfigSchema: z = llmRetry.Config @@ -118,8 +139,9 @@ export const Config = z.intersect([ toolBash: ToolBashConfigSchema, toolTasks: z.union([z.const(false), ToolTasksConfigSchema]), invariants: InvariantService.Config, + goals: z.union([z.const(false), GoalConfigSchema]), llmRetry: LlmRetryConfigSchema, - }) as unknown as z>, + }) as unknown as z>, ]) as unknown as z /** @@ -139,6 +161,7 @@ export function pickSpineConfig(config: Omit): Omit { expect(ctx.get('tasks')).toBeDefined() expect(ctx.get('invariants')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined() + expect(ctx.get('goals')).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('opts into the configured persisted-goal domain, tools, and same-session driver', async () => { + const ctx = await mount({ + workspaceContext: false, + agents: [{ id: SessionId('configured-goal'), provider: 'mock', model: 'mock' }], + goals: { + domain: { defaultMaxGoalRounds: 17 }, + tool: { blockedAfterConsecutiveRounds: 5 }, + }, + }) + const agent = ctx.agents.list()[0] + if (agent === undefined) throw new Error('configured goal test has no live agent') + expect(ctx.goals.create(agent, { objective: 'configured' })).toMatchObject({ + objective: 'configured', maxGoalRounds: 17, + }) + expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name)) + .toEqual(['create_goal', 'get_goal', 'update_goal']) + expect((await ctx.systemPrompt.assemble()).sections.find(section => section.name === 'tool:goal')?.text) + .toContain('at least 5 consecutive goal rounds') + await ctx.fiber.dispose() + }) + + it('accepts an explicit false goal composition without mounting it', async () => { + const ctx = await mount({ workspaceContext: false, goals: false }) + expect(ctx.get('goals')).toBeUndefined() + expect(ctx.tools.get('get_goal')).toBeUndefined() await ctx.fiber.dispose() }) @@ -245,6 +274,23 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('uses owner defaults for a schema-bypassing empty goal opt-in', async () => { + const ctx = new Context() + agentCore.apply(ctx, { + workspaceContext: false, + agents: [{ id: SessionId('defaulted-goal'), provider: 'mock', model: 'mock' }], + goals: {}, + }) + await new Promise(resolve => setTimeout(resolve, 50)) + const agent = ctx.agents.list()[0] + if (agent === undefined) throw new Error('default goal test has no live agent') + expect(ctx.goals.create(agent, { objective: 'defaulted' })).toMatchObject({ + objective: 'defaulted', maxGoalRounds: 256, + }) + expect(ctx.tools.get('get_goal')).toBeDefined() + await ctx.fiber.dispose() + }) + it('loads workspace instructions into requests through the bundled spine', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-workspace-context-')) try { diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index 9b897d1674..6938f422b9 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -41,6 +41,15 @@ { "path": "../../core/agent" }, + { + "path": "../../goal/goal" + }, + { + "path": "../../goal/tool-goal" + }, + { + "path": "../../goal/goal-session" + }, { "path": "../../context/workspace-context" }, diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index b544e989fc..2a10140f18 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-tui-demo -The full-screen terminal app: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). Its `bin` boots a leaf `cordis.yml`. +The full-screen terminal app: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). Its `bin` boots a leaf `cordis.yml`. Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This package requires a TTY pair and has no line-oriented fallback. @@ -9,6 +9,8 @@ Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and | Plugin | Why it is here | |---|---| | `@deepseek-ai/dsh-agent-spine-demo` | Shared services, model-facing tools, and one configured `main` agent | +| `@deepseek-ai/dsh-commands` | Human-only discovery and dispatch consumed by the TUI and command plugins | +| `@deepseek-ai/dsh-command-goal` | Direct `/goal` status and mutation over the spine's persisted-goal stack | | `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` | | `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service | | `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays | @@ -30,6 +32,7 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le | `skills` | owner defaults | Skill registry, local provider, and tool config | | `toolBash` | owner defaults | Model-facing bash tool config | | `toolTasks` | owner defaults | Background-task control-tool config, or `false` | +| `goals` | owner defaults | Persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer | | `workspaceContext` | required | Workspace-instruction config, or `false` | | `persistenceRoot` | `./.sessions` | JSONL persistence root | | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | @@ -70,7 +73,7 @@ Fresh runs mint a `main-session-` session id and pass it to both the TUI a #### What the model sees -Each non-empty editor submission becomes a user message; a submission during a running turn becomes steering. The shared spine contributes the configured persona, workspace instructions, skill catalog, and visible tool schemas. TUI rendering itself is not model-visible. +Each non-empty non-command editor submission becomes a user message; a submission during a running turn becomes steering. Slash-command input and output remain human-only, while accepted `/goal` mutations append domain-owned model-visible state. The shared spine contributes the configured persona, workspace instructions, skill catalog, goal controls, and visible tool schemas. TUI rendering itself is not model-visible. #### Token effect diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json index 841505fb00..bcaa64c984 100644 --- a/packages/examples/tui-demo/package.json +++ b/packages/examples/tui-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-tui-demo", - "description": "Full-screen terminal app: agent spine + JSONL persistence + pi-tui front door + pre-created main agent", + "description": "Full-screen terminal app: agent spine + persisted goals + human commands + JSONL persistence + pi-tui front door + pre-created main agent", "version": "0.0.1", "private": true, "type": "module", @@ -40,6 +40,8 @@ "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", + "@deepseek-ai/dsh-command-goal": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", @@ -59,6 +61,8 @@ "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts index c4a13948e8..fc6d6cc8ad 100644 --- a/packages/examples/tui-demo/src/index.ts +++ b/packages/examples/tui-demo/src/index.ts @@ -1,8 +1,8 @@ /** * Full-screen terminal app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) - * plus JSONL persistence, keyboard-backed user interaction, and one pre-created - * agent whose exact session identity the TUI drives. Swappable adapters, - * executors, optional tools, and HMR stay in the leaf. This Loader plugin + * plus persisted goals, human commands, JSONL persistence, keyboard-backed + * user interaction, and one pre-created agent whose exact session identity the + * TUI drives. Swappable adapters, executors, optional tools, and HMR stay in the leaf. This Loader plugin * intentionally exposes named exports only; a default export would hide its * `Config` schema (see docs/postmortem/0001). * @module @deepseek-ai/dsh-tui-demo @@ -13,6 +13,8 @@ import { randomUUID } from 'node:crypto' import z from 'schemastery' import { SessionId } from '@deepseek-ai/dsh-session' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' +import CommandService from '@deepseek-ai/dsh-commands' +import * as commandGoal from '@deepseek-ai/dsh-command-goal' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import SessionPersistenceJsonl, { @@ -57,6 +59,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ toolTasks?: NonNullable + /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ + goals?: agentCore.GoalConfig | false /** Persisted session id to resume instead of creating a fresh session. */ resumeSessionId?: string /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ @@ -82,6 +86,7 @@ export const Config: z = z.object({ skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), + goals: z.union([z.const(false), agentCore.GoalConfigSchema]), resumeSessionId: z.string(), workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) @@ -97,6 +102,9 @@ export const Config: z = z.object({ export function composeTuiApp(ctx: Context, config: Config): void { const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) + const goals = config.goals ?? {} + ctx.plugin(CommandService) + if (goals !== false) ctx.plugin(commandGoal) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT, ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), @@ -109,6 +117,7 @@ export function composeTuiApp(ctx: Context, config: Config): void { }) ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), + goals, agents: [{ id: SessionId('main'), provider: config.provider, diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index 673f3e4f05..73aa61430a 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -41,18 +41,22 @@ describe('dsh-tui-demo app', () => { }) expect(calls.map(call => call.name)).toEqual([ + 'CommandService', + 'command-goal', 'SessionPersistenceJsonl', 'UserInteractionService', 'ui-tui', 'agent-spine-demo', 'tool-ask-user', ]) - expect(calls[0]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' }) - const tuiConfig = calls[2]?.config as { sessionId: string } + expect(calls[0]?.config).toBeUndefined() + expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' }) + const tuiConfig = calls[4]?.config as { sessionId: string } expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 }) expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - const spineConfig = calls[3]?.config as { + const spineConfig = calls[5]?.config as { readonly agents: Array> + readonly goals: Record readonly maxParallelToolCalls: number readonly persona: string readonly toolOrder: string[] @@ -63,6 +67,7 @@ describe('dsh-tui-demo app', () => { persona: 'test persona', toolOrder: ['zulu', TOOL_ORDER_REST], tools: { mode: 'code' }, + goals: {}, }) expect(spineConfig.agents[0]).toMatchObject({ id: 'main', @@ -82,9 +87,9 @@ describe('dsh-tui-demo app', () => { workspaceContext: false, }) - expect(calls[0]?.config).toEqual({ root: './.sessions' }) - expect(calls[2]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' }) - expect((calls[3]?.config as { agents: Array> }).agents[0]).toMatchObject({ + expect(calls[2]?.config).toEqual({ root: './.sessions' }) + expect(calls[4]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' }) + expect((calls[5]?.config as { agents: Array> }).agents[0]).toMatchObject({ id: 'main', resumeSessionId: 'persisted-session', }) @@ -96,13 +101,16 @@ describe('dsh-tui-demo app', () => { provider: 'mock', model: 'mock-model', resumeSessionId: '', + goals: false, workspaceContext: false, }) - const tuiConfig = calls[2]?.config as { sessionId: string } + const tuiConfig = calls[3]?.config as { sessionId: string } expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - expect((calls[3]?.config as { agents: Array> }).agents[0]) + expect((calls[4]?.config as { agents: Array> }).agents[0]) .toMatchObject({ sessionId: tuiConfig.sessionId }) + expect(calls.map(call => call.name)).not.toContain('command-goal') + expect(calls[4]?.config).toMatchObject({ goals: false }) }) it('has the namespace-plugin export shape so the Loader keeps its schema', () => { diff --git a/packages/examples/tui-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json index 78b1ddc6a0..21e6eff01f 100644 --- a/packages/examples/tui-demo/tsconfig.json +++ b/packages/examples/tui-demo/tsconfig.json @@ -26,6 +26,12 @@ { "path": "../../core/session" }, + { + "path": "../../ui/commands" + }, + { + "path": "../../goal/command-goal" + }, { "path": "../agent-spine-demo" }, diff --git a/packages/goal/README.md b/packages/goal/README.md index 45f1b4f135..95cd975b69 100644 --- a/packages/goal/README.md +++ b/packages/goal/README.md @@ -5,5 +5,8 @@ The goal family owns durable objective state independently of the model-facing t | Package | Role | ctx key | |---|---|---| | `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` | +| `goal-session/` | Same-session goal-round admission, outcome mapping, and lifecycle race fencing | — | +| `tool-goal/` | Model-facing read/create/update tools with execution-time authority checks | — | +| `command-goal/` | Human-facing `/goal` status and lifecycle control over the command plane | — | Goal 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/command-goal/README.md b/packages/goal/command-goal/README.md new file mode 100644 index 0000000000..d47e5df1e4 --- /dev/null +++ b/packages/goal/command-goal/README.md @@ -0,0 +1,56 @@ +# @deepseek-ai/dsh-command-goal + +Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI and ACP execute it without a model turn. The [human goal-command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions. + +## Command contract + +| Input | Result | +|---|---| +| `/goal` | Show the current objective, durable phase, round count/cap, process-local activation, and valid next commands; a blocked goal also shows its policy code and explanation, while no goal shows usage. | +| `/goal ` | Create and arm a goal, or replace a completed goal with a fresh identity. An unfinished goal is never replaced without an explicit clear. | +| `/goal edit ` | Edit the current objective without changing its phase or activation. Editing a completed goal creates a fresh active goal. | +| `/goal pause` | Pause an active goal and disarm continuation. | +| `/goal resume` | Resume a stopped goal or rearm an active goal after session resume/fork, subject to its remaining round cap. | +| `/goal clear` | Clear the current pointer while retaining its durable history and tombstone. | + +Control words are case-insensitive only when they occupy the complete input. Every other non-empty suffix is an objective, so `/goal pause after verification` creates that literal objective. The goal domain trims and validates objectives. Because the generic command plane has no modal editor or confirmation primitive, `edit` takes its replacement inline and an unfinished replacement returns a direct error instructing the user to edit or clear. + +Expected domain rejections become stable direct command errors without exposing branded ids or revisions. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; every accepted mutation is persisted and made model-visible by `dsh-goal` rather than by this plugin. + +## Composition + +The producer injects `commands` and `goals`. A custom app mounts their owners plus this plugin; automatic continuation remains an independent choice: + +```yaml +- id: commands + name: '@deepseek-ai/dsh-commands' +- id: goal + name: '@deepseek-ai/dsh-goal' +- id: command-goal + name: '@deepseek-ai/dsh-command-goal' +``` + +The TUI and ACP demo apps enable the complete persisted-goal stack and this command by default; `goals: false` removes both. The UI-less `agent-spine-demo` requires an explicit `goals: {}` so headless one-shot callers do not silently change from one physical turn to a multi-round operation. + +## Model Experience + +### Human `/goal` control + +#### What the model sees + +The slash input and direct status/error output are absent from model requests. An accepted mutation later appears through the goal domain's raw `` snapshot or clear tombstone; this preserves the model-visible-is-logged invariant without logging presentation text. + +#### Token effect + +Reading status or receiving a direct command error adds no model tokens. Each accepted mutation adds the goal domain's retained full snapshot, and an enabled same-session driver may add later goal-round prompts. + +#### KV Cache effect + +Command discovery and direct output do not affect the cache. A mutation appends after the reusable history prefix; later compaction may replace the derived-history suffix. + +## Known Limitations and Deferred Work + +- **Plain-text interaction only** — the generic command registry has no modal edit form or replacement-confirmation callback; inline edit and explicit clear keep destructive intent deterministic on both TUI and ACP. +- **No per-command round-cap argument** — `defaultMaxGoalRounds` remains deployment config, while a direct human request may ask the model to edit `max_goal_rounds` through the separately authorized goal tool. +- **No continuous status widget** — bare `/goal` is the portable observation surface; adapter-specific badges and reconnectable command output remain future UI work. +- **TUI and ACP only** — the headless CLI and JSON-RPC adapters do not consume `ctx.commands`. Ordinary human prompts can still authorize the model-facing goal tools when those are composed. diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json new file mode 100644 index 0000000000..b1007d79f3 --- /dev/null +++ b/packages/goal/command-goal/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-command-goal", + "description": "Human-facing slash command for persisted same-session goals", + "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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-commands": "^0.0.1", + "@deepseek-ai/dsh-goal": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/goal/command-goal/src/index.ts b/packages/goal/command-goal/src/index.ts new file mode 100644 index 0000000000..93ed7923b8 --- /dev/null +++ b/packages/goal/command-goal/src/index.ts @@ -0,0 +1,170 @@ +/** + * Human-facing `/goal` command over the persisted same-session goal domain. + * @module @deepseek-ai/dsh-command-goal + */ + +import type { Context } from 'cordis' +import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' +import { GoalError } from '@deepseek-ai/dsh-goal' +import type { GoalPhase, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' + +export const name = 'command-goal' +export const inject = ['commands', 'goals'] + +const USAGE = 'Usage: /goal [|clear|edit |pause|resume]' + +type GoalCommand = + | { readonly kind: 'show' } + | { readonly kind: 'create'; readonly objective: string } + | { readonly kind: 'edit'; readonly objective: string } + | { readonly kind: 'invalid-edit' } + | { readonly kind: 'pause' } + | { readonly kind: 'resume' } + | { readonly kind: 'clear' } + +/** Fail loudly if a locally closed union gains an unhandled member. */ +/* v8 ignore start -- closed-union backstop is unreachable without violating the TypeScript contract */ +function assertNever(value: never, label: string): never { + throw new TypeError(`unknown ${label}: ${String(value)}`) +} +/* v8 ignore stop */ + +/** Parse only the grammar owned by `/goal`; arbitrary other input is an objective. */ +function parseGoalCommand(rawInput: string): GoalCommand { + const input = rawInput.trim() + if (input.length === 0) return { kind: 'show' } + const control = input.toLowerCase() + if (control === 'clear') return { kind: 'clear' } + if (control === 'pause') return { kind: 'pause' } + if (control === 'resume') return { kind: 'resume' } + if (control === 'edit') return { kind: 'invalid-edit' } + if (/^edit(?=\s)/iu.test(input)) return { kind: 'edit', objective: input.slice(4).trim() } + return { kind: 'create', objective: input } +} + +/** Human label for one durable goal phase. */ +function phaseLabel(phase: GoalPhase): string { + switch (phase) { + case 'active': return 'active' + case 'paused': return 'paused' + case 'blocked': return 'blocked' + case 'complete': return 'complete' + /* v8 ignore next 2 -- GoalPhase is closed and every member is handled above */ + default: return assertNever(phase, 'goal phase') + } +} + +/** Commands that are meaningful from one exact live state. */ +function commandHint(goal: GoalView): string { + if (goal.phase === 'active') { + return goal.activation === 'armed' + ? '/goal edit , /goal pause, /goal clear' + : '/goal edit , /goal resume, /goal clear' + } + switch (goal.phase) { + case 'paused': + case 'blocked': + return '/goal edit , /goal resume, /goal clear' + case 'complete': + return '/goal , /goal clear' + /* v8 ignore next 2 -- the active branch and every non-active phase are handled above */ + default: return assertNever(goal.phase, 'goal phase') + } +} + +/** Render direct UI output without exposing compare-and-set internals. */ +function renderGoal(title: string, goal: GoalView): CommandResult { + const reason = goal.phase === 'blocked' ? goal.blockedReason : undefined + /* v8 ignore next -- durable replay guarantees every blocked goal carries its validated reason */ + if (goal.phase === 'blocked' && reason === undefined) throw new TypeError('blocked goal is missing its reason') + const blocker = reason === undefined ? [] : [`Blocker: ${reason.code}: ${reason.message}`] + return { + kind: 'success', + text: [ + title, + `Status: ${phaseLabel(goal.phase)}`, + ...blocker, + `Objective: ${goal.objective}`, + `Rounds: ${goal.roundsStarted}/${goal.maxGoalRounds}`, + `Activation: ${goal.activation}`, + '', + `Commands: ${commandHint(goal)}`, + ].join('\n'), + } +} + +/** Exact current compare-and-set ref. */ +function goalRef(goal: GoalView): GoalRef { + return { id: goal.id, revision: goal.revision } +} + +/** Direct error for an operation that requires a current goal. */ +function missingGoal(action: string): CommandResult { + return { + kind: 'error', + text: `No goal is currently set; /goal ${action} requires one. ${USAGE}`, + } +} + +/** Execute one parsed human command through the domain that owns persistence. */ +function executeGoalCommand(ctx: Context, invocation: CommandInvocation): CommandResult { + const command = parseGoalCommand(invocation.rawInput) + try { + const current = ctx.goals.get(invocation.agent) + switch (command.kind) { + case 'show': + return current === undefined + ? { kind: 'success', text: `No goal is currently set.\n${USAGE}` } + : renderGoal('Goal', current) + case 'invalid-edit': + return { kind: 'error', text: `Goal editing requires a replacement objective.\n${USAGE}` } + case 'create': + if (current !== undefined && current.phase !== 'complete') { + return { + kind: 'error', + text: `A goal is already ${phaseLabel(current.phase)}. Use /goal edit to change it or /goal clear before replacing it.`, + } + } + return renderGoal('Goal created', ctx.goals.create(invocation.agent, { objective: command.objective })) + case 'edit': + if (current === undefined) return missingGoal('edit') + if (current.phase === 'complete') { + return renderGoal('Goal created', ctx.goals.create(invocation.agent, { objective: command.objective })) + } + return renderGoal( + 'Goal updated', + ctx.goals.edit(invocation.agent, goalRef(current), { objective: command.objective }), + ) + case 'pause': + if (current === undefined) return missingGoal('pause') + return renderGoal('Goal paused', ctx.goals.pause(invocation.agent, goalRef(current))) + case 'resume': + if (current === undefined) return missingGoal('resume') + return renderGoal('Goal resumed', ctx.goals.resume(invocation.agent, goalRef(current))) + case 'clear': + if (current === undefined) return { kind: 'success', text: 'No goal to clear.' } + ctx.goals.clear(invocation.agent, goalRef(current)) + return { kind: 'success', text: 'Goal cleared.' } + /* v8 ignore next 2 -- GoalCommand is closed and every member is handled above */ + default: return assertNever(command, 'goal command') + } + } catch (error: unknown) { + if (error instanceof GoalError) { + return { + kind: 'error', + text: 'The goal command is not valid for the current state. Run /goal to view available commands.', + } + } + throw error + } +} + +/** Register the Codex-shaped `/goal` command for every composed command adapter. */ +export function apply(ctx: Context): void { + ctx.commands.register({ + name: 'goal', + description: 'set or view the goal for a long-running task', + input: { hint: '[|clear|edit |pause|resume]' }, + handler: invocation => executeGoalCommand(ctx, invocation), + }) +} diff --git a/packages/goal/command-goal/src/invariant.ts b/packages/goal/command-goal/src/invariant.ts new file mode 100644 index 0000000000..795294b4e8 --- /dev/null +++ b/packages/goal/command-goal/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-command-goal`. + * @module @deepseek-ai/dsh-command-goal/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-command-goal' + +/** Cordis companion plugin name. */ +export const name = 'command-goal-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this command adapter owns no event stream or state projection; accepted + * mutations are checked by the goal domain and command dispatch behavior is covered by package tests. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts new file mode 100644 index 0000000000..35994ecb71 --- /dev/null +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' +import CommandService from '@deepseek-ai/dsh-commands' +import GoalService from '@deepseek-ai/dsh-goal' +import type { GoalRef } from '@deepseek-ai/dsh-goal' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import * as commandGoal from '@deepseek-ai/dsh-command-goal' + +interface Harness { + readonly ctx: Context + readonly agent: Agent + readonly session: Session + readonly plugin: Awaited> +} + +/** Number the next balanced injection or message turn. */ +function nextTurn(session: Session): number { + return session.events.reduce( + (maximum, event) => event.type === 'turn/start' ? Math.max(maximum, event.data.turn) : maximum, + 0, + ) + 1 +} + +/** Append one idle injection using the public Agent contract's balanced shape. */ +function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void { + const source: MessageSource = options?.source ?? { kind: 'user' } + const turn = nextTurn(session) + session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('context/message', { + content, + source, + ...options?.meta === undefined ? {} : { meta: options.meta }, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) +} + +/** Build a live idle agent accepted by the exact-identity goal service. */ +function stubAgent(id: string): { agent: Agent; session: Session } { + const session = new Session(SessionId(id)) + let status: AgentStatus = 'idle' + const agent: Agent = { + id: session.id, + options: {}, + session, + ctx: new Context(), + get status() { return status }, + send() {}, + steer() {}, + inject(content, options) { appendInjection(session, content, options) }, + cancel() { status = 'idle' }, + whenIdle() { return Promise.resolve() }, + } + return { agent, session } +} + +/** Mount the real command registry, goal domain, and producer. */ +async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(CommandService) + await ctx.plugin(AgentRegistry) + await ctx.plugin(GoalService) + const plugin = await ctx.plugin(commandGoal) + const { agent, session } = stubAgent(`command-goal-${Math.random()}`) + ctx.agents.register(agent) + return { ctx, agent, session, plugin } +} + +/** Execute `/goal` through the same registry boundary as a UI adapter. */ +async function run(test: Harness, suffix = ''): Promise>>> { + const result = await test.ctx.commands.execute( + test.agent, + `/goal${suffix}`, + new AbortController().signal, + ) + if (result === undefined) throw new Error('goal command was not registered') + return result +} + +/** Current exact compare-and-set ref. */ +function ref(goal: NonNullable>): GoalRef { + return { id: goal.id, revision: goal.revision } +} + +describe('@deepseek-ai/dsh-command-goal registration', () => { + it('registers one global command with Loader-safe exports and disposes it', async () => { + const test = await harness() + expect(commandGoal.name).toBe('command-goal') + expect(commandGoal.inject).toEqual(['commands', 'goals']) + expect('default' in commandGoal).toBe(false) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(commandGoal)).toBe(commandGoal) + + expect(test.ctx.commands.list(test.agent)).toContainEqual({ + name: 'goal', + description: 'set or view the goal for a long-running task', + input: { hint: '[|clear|edit |pause|resume]' }, + }) + expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined() + + await test.plugin.dispose() + expect(test.ctx.commands.find(test.agent, 'goal')).toBeUndefined() + }) +}) + +describe('/goal human command', () => { + it('shows an empty status without mutating the session', async () => { + const test = await harness() + await expect(run(test)).resolves.toEqual({ + kind: 'success', + text: 'No goal is currently set.\nUsage: /goal [|clear|edit |pause|resume]', + }) + expect(test.session.events).toEqual([]) + }) + + it('creates a trimmed objective and refuses silent replacement of unfinished work', async () => { + const test = await harness() + const created = await run(test, '\n finish the release ') + expect(created.kind).toBe('success') + expect(created.text).toContain('Goal created\nStatus: active') + expect(created.text).toContain('Objective: finish the release') + expect(created.text).toContain('Rounds: 0/256') + expect(created.text).toContain('Activation: armed') + expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release') + expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end']) + + const count = test.session.events.length + await expect(run(test, ' replacement')).resolves.toEqual({ + kind: 'error', + text: 'A goal is already active. Use /goal edit to change it or /goal clear before replacing it.', + }) + expect(test.session.events).toHaveLength(count) + }) + + it('treats only exact control words as controls', async () => { + const test = await harness() + await run(test, ' pause everything only after verification') + expect(test.ctx.goals.get(test.agent)?.objective).toBe('pause everything only after verification') + }) + + it('edits inline, requires an objective, and starts a new goal when the old one is complete', async () => { + const empty = await harness() + const invalidEdit = await run(empty, ' edit') + expect(invalidEdit.kind).toBe('error') + expect(invalidEdit.text).toContain('requires a replacement objective') + const missingEdit = await run(empty, ' edit replacement') + expect(missingEdit.kind).toBe('error') + expect(missingEdit.text).toContain('/goal edit requires one') + + const test = await harness() + await run(test, ' first') + const first = test.ctx.goals.get(test.agent)! + const updated = await run(test, ' EDIT\n second ') + expect(updated.kind).toBe('success') + expect(updated.text).toContain('Goal updated') + expect(test.ctx.goals.get(test.agent)).toMatchObject({ id: first.id, objective: 'second', revision: 2 }) + + const current = test.ctx.goals.get(test.agent)! + test.ctx.goals.complete(test.agent, ref(current)) + const replacement = await run(test, ' edit third') + expect(replacement.kind).toBe('success') + expect(replacement.text).toContain('Goal created') + expect(test.ctx.goals.get(test.agent)).toMatchObject({ objective: 'third', revision: 1 }) + expect(test.ctx.goals.get(test.agent)?.id).not.toBe(first.id) + }) + + it('returns direct missing-state results for pause, resume, and clear', async () => { + const test = await harness() + const missingPause = await run(test, ' pause') + expect(missingPause.kind).toBe('error') + expect(missingPause.text).toContain('/goal pause requires one') + const missingResume = await run(test, ' resume') + expect(missingResume.kind).toBe('error') + expect(missingResume.text).toContain('/goal resume requires one') + await expect(run(test, ' clear')).resolves.toEqual({ kind: 'success', text: 'No goal to clear.' }) + }) + + it('pauses, resumes, clears, and converts expected domain rejections to command errors', async () => { + const test = await harness() + await run(test, ' work') + const redundantResume = await run(test, ' RESUME') + expect(redundantResume).toEqual({ + kind: 'error', + text: 'The goal command is not valid for the current state. Run /goal to view available commands.', + }) + const paused = await run(test, ' PAUSE') + expect(paused.kind).toBe('success') + expect(paused.text).toContain('Goal paused') + expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'paused', activation: 'disarmed' }) + const resumed = await run(test, ' resume') + expect(resumed.kind).toBe('success') + expect(resumed.text).toContain('Goal resumed') + expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', activation: 'armed' }) + await expect(run(test, ' clear')).resolves.toEqual({ kind: 'success', text: 'Goal cleared.' }) + expect(test.ctx.goals.get(test.agent)).toBeUndefined() + }) + + it('shows every durable phase and distinguishes disarmed active state', async () => { + const test = await harness() + test.ctx.goals.create(test.agent, { objective: 'state matrix', maxGoalRounds: 1 }) + test.ctx.goals.disarm(test.agent) + expect((await run(test)).text) + .toContain('Status: active\nObjective: state matrix\nRounds: 0/1\nActivation: disarmed') + expect((await run(test)).text).toContain('/goal resume') + + let goal = test.ctx.goals.get(test.agent)! + goal = test.ctx.goals.resume(test.agent, ref(goal)) + goal = test.ctx.goals.pause(test.agent, ref(goal)) + expect((await run(test)).text).toContain('Status: paused') + + goal = test.ctx.goals.resume(test.agent, ref(goal)) + goal = test.ctx.goals.block(test.agent, ref(goal), { + code: 'upstream-unavailable', + message: 'Provider unavailable', + }) + const blocked = await run(test) + expect(blocked.text).toContain('Status: blocked') + expect(blocked.text).toContain('Blocker: upstream-unavailable: Provider unavailable') + + goal = test.ctx.goals.resume(test.agent, ref(goal)) + test.ctx.goals.complete(test.agent, ref(goal)) + const complete = await run(test) + expect(complete.text).toContain('Status: complete') + expect(complete.text).toContain('Commands: /goal , /goal clear') + }) + + it('does not turn unexpected implementation failures into expected command results', async () => { + const test = await harness() + vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { throw new Error('unexpected failure') }) + await expect(run(test)).rejects.toThrow('unexpected failure') + }) +}) diff --git a/packages/goal/command-goal/tsconfig.json b/packages/goal/command-goal/tsconfig.json new file mode 100644 index 0000000000..8b03235814 --- /dev/null +++ b/packages/goal/command-goal/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../ui/commands" + }, + { + "path": "../goal" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/goal/goal-session/README.md b/packages/goal/goal-session/README.md new file mode 100644 index 0000000000..2ad4b161a3 --- /dev/null +++ b/packages/goal/goal-session/README.md @@ -0,0 +1,71 @@ +# @deepseek-ai/dsh-goal-session + +Same-session continuation driver for [`ctx.goals`](../goal/README.md). It turns an active, armed goal into sequential [goal rounds](../../../docs/glossary.md#goal-round) through the public `Agent` and session seams; the [same-session driver Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md) owns the race and lifecycle rationale. + +## Composition + +```yaml +- id: goal + name: '@deepseek-ai/dsh-goal' + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +- id: goal-session + name: '@deepseek-ai/dsh-goal-session' +``` + +The plugin has no tunable configuration. `maxGoalRounds` belongs to the goal definition, while the model-facing blocked threshold belongs to [`dsh-tool-goal`](../tool-goal/README.md); duplicating either value in the driver could produce divergent policy. + +## Round contract + +When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `` prompt with `GoalMessageSource`. Admission through `agent/prompt-submit` verifies the complete queued record and current goal both before and after downstream prompt hooks; only the accepted `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number. + +One goal round owns one ordinary session turn, and that turn may contain several model/tool steps. Human messages remain ordinary turns and do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until that work settles; a pending automatic prompt in a mixed batch is rejected and re-reserved only after the agent becomes idle. + +The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, treats the current workspace, tool results, and durable session state as authoritative, requires evidence before completion, and tells the model to leave the goal active when work remains. Quoting preserves multiline or tag-like objective text as data. Goal lifecycle mutations still require the independent authority checks in `dsh-tool-goal`. + +## Settlement policy + +| Durable turn outcome | Goal action | Automatic retry | +|---|---|---| +| `completed` with goal still active and armed | admit the next round, or block with code `round-limit` at the cap | yes | +| cancellation of a reserved/admitted goal round, or its `aborted` outcome | `paused` | no | +| cancellation with no goal-round attempt | keep durable phase; disarm activation | no | +| `error` with `RATE_LIMIT` or `QUOTA` | `blocked` with code `usage-limited` | no | +| other `error`, `max-tokens`, or a non-stale prompt rejection | `blocked` with a diagnostic code and message | no | +| durability failure, disposal, interruption, or unknown future outcome | disarm or block for inspection | no | + +A goal mutation made during its round supersedes settlement of the older revision. Completion, pause, blocking, and edits therefore remain authoritative even if the physical turn closes afterward. No abnormal result is retried automatically. + +## Lifecycle and durability + +`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A closing flush failure arrives through `agent/error`; the driver associates it with the exact closed turn even if a later one-shot injection has appended another turn, then disarms before another round can start. + +Activation is never inherited when this plugin loads over an existing agent. `GoalService.disarm()` removes process-local authority without changing durable phase, revision, or history; explicit human-authorized resume records the later reactivation. The same rule applies after session resume and fork through the goal domain's `agent/session-start` handling. + +Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` before clearing queues or aborting a step. The plugin durably pauses an active goal only when the cancellation owns a reserved or admitted goal attempt; cancellation of unrelated human work merely disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels an admitted round, and awaits the driver plus agent quiescence while its event fence remains installed. + +## Model Experience + +### Goal-round prompt + +#### What the model sees + +Each admitted round is one retained user-role `` block naming the full objective and positive round number. Earlier human messages, goal-state snapshots, assistant output, and tool records remain in the same session history. + +#### Token effect + +One fixed instruction block plus the objective is added per admitted round. Later requests resend retained rounds until compaction shadows them; no fresh agent or copied conversation prefix is created. + +#### KV Cache effect + +Append-only within an epoch: each admitted round extends the existing conversation after its reusable prefix. Compaction may replace the derived-history suffix and move the reusable boundary. + +## Known Limitations and Deferred Work + +- **No independent evaluator** — the model-facing goal policy decides when evidence is sufficient for completion and whether a blocker is semantically unchanged; evaluator-backed certification remains deferred. +- **Same-session execution only** — this package deliberately does not spawn a fresh agent, fork a session prefix, or implement Ralph-style independent attempts; that workflow belongs to its own plugin layer. +- **Accepted-queue unload race** — Cordis plugin unload is asynchronous. A goal prompt already accepted by the agent inbox can begin and consume its round before unload starts; teardown then cancels the request, disarms the goal, and awaits quiescence. No later round starts. +- **Round cap, not resource budget** — token, currency, time, and provider quota policies remain independent; observed `RATE_LIMIT` and `QUOTA` stops only map into the blocked reason code `usage-limited`. +- **No abnormal auto-retry** — transient provider and persistence failures require a later human-authorized resume rather than an implicit retry policy. diff --git a/packages/goal/goal-session/package.json b/packages/goal/goal-session/package.json new file mode 100644 index 0000000000..190cb795ad --- /dev/null +++ b/packages/goal/goal-session/package.json @@ -0,0 +1,49 @@ +{ + "name": "@deepseek-ai/dsh-goal-session", + "description": "Race-fenced same-session goal-round driver", + "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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.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-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "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/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts new file mode 100644 index 0000000000..24364a4983 --- /dev/null +++ b/packages/goal/goal-session/src/index.ts @@ -0,0 +1,456 @@ +/** + * Same-session goal-round driver over public agent, session, and goal seams. + * @module @deepseek-ai/dsh-goal-session + */ + +import { isDeepStrictEqual } from 'node:util' +import { FiberState } from 'cordis' +import type { Context } from 'cordis' +import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' +import { assertNever } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import { classifyGoalRound } from './outcome.ts' +import type { GoalRoundOutcome } from './outcome.ts' +import { renderGoalRoundPrompt } from './prompt.ts' + +export { classifyGoalRound } from './outcome.ts' +export type { GoalRoundOutcome } from './outcome.ts' +export { renderGoalRoundPrompt } from './prompt.ts' + +export const name = 'goal-session' +export const inject = ['agents', 'goals', 'sessions'] + +const STALE_ROUND_REASON = 'stale goal-round reservation' + +/** Identity reserved before a goal continuation enters the agent inbox. */ +interface RoundIdentity { + readonly goalId: GoalRef['id'] + readonly revision: number + readonly round: number +} + +/** One queued or admitted attempt, retained until its physical turn settles. */ +interface RoundAttempt extends RoundIdentity { + readonly content: ContentBlock[] + phase: 'queued' | 'admitted' + turn: number | undefined + reason: TurnEndReason | undefined + rejectedReason: string | undefined + stale: boolean +} + +/** Serialized process-local scheduling state for one exact Agent lifecycle. */ +interface DriverState { + readonly agent: Agent + attempt: RoundAttempt | undefined + openTurn: number | undefined + competingQueued: boolean + needsCheckpoint: boolean + requested: boolean + run: Promise | undefined + stopping: boolean + readonly flushFailedTurns: Set +} + +/** Whether a source identifies an automatic, positive-numbered goal round. */ +function isGoalRoundSource(source: MessageSource): source is GoalMessageSource { + return source.kind === 'goal' && source.round > 0 +} + +/** Compare a source to one reserved identity. */ +function sameRound(source: GoalMessageSource, round: RoundIdentity): boolean { + return source.goalId === round.goalId + && source.revision === round.revision + && source.round === round.round +} + +/** Compare the complete queued record to the driver's reservation. */ +function sameQueued(content: ContentBlock[], source: MessageSource, attempt: RoundAttempt): boolean { + return isGoalRoundSource(source) && sameRound(source, attempt) && isDeepStrictEqual(content, attempt.content) +} + +/** Exact current ref for a view. */ +function goalRef(goal: GoalView): GoalRef { + return { id: goal.id, revision: goal.revision } +} + +/** Human-readable unexpected values for logs. */ +function renderThrown(value: unknown): string { + return value instanceof Error ? value.message : String(value) +} + +/** Install automatic same-session continuation and its race fences. */ +export function apply(ctx: Context): void { + const states = new Map() + + /** Create state for an exact currently live agent. */ + function stateFor(agent: Agent): DriverState { + const existing = states.get(agent) + if (existing !== undefined) return existing + const state: DriverState = { + agent, + attempt: undefined, + openTurn: undefined, + competingQueued: false, + needsCheckpoint: false, + requested: false, + run: undefined, + stopping: false, + flushFailedTurns: new Set(), + } + states.set(agent, state) + return state + } + + /** Read only when the exact Agent remains live. */ + function currentGoal(state: DriverState): GoalView | undefined { + if (ctx.agents.get(state.agent.id) !== state.agent || state.agent.status === 'disposed') return undefined + return ctx.goals.get(state.agent) + } + + /** Whether this exact lifecycle is quiescent with no competing prompt. */ + function readyToDrive(state: DriverState): boolean { + return ctx.fiber.state === FiberState.ACTIVE + && !state.stopping + && ctx.agents.get(state.agent.id) === state.agent + && state.agent.status === 'idle' + && !state.competingQueued + } + + /** Recheck every condition that an awaited checkpoint may have changed. */ + function readyAfterCheckpoint(state: DriverState): boolean { + return readyToDrive(state) && !state.needsCheckpoint + } + + /** Remove automatic authority while preserving the durable phase. */ + function disarm(state: DriverState): void { + try { + const goal = currentGoal(state) + if (goal?.activation === 'armed') ctx.goals.disarm(state.agent) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: could not disarm agent "${state.agent.id}": ${renderThrown(error)}`) + } + } + + /** Apply one closed-round outcome only to the exact still-current revision. */ + function applyOutcome(state: DriverState, goal: GoalView, outcome: GoalRoundOutcome): void { + const ref = goalRef(goal) + switch (outcome.kind) { + case 'continue': + return + case 'pause': + ctx.goals.pause(state.agent, ref) + return + case 'blocked': + ctx.goals.block(state.agent, ref, { code: outcome.code, message: outcome.message }) + return + case 'disarm': + ctx.goals.disarm(state.agent) + return + /* v8 ignore next 2 -- GoalRoundOutcome is closed and every member is handled above */ + default: + assertNever(outcome, 'goal round outcome') + } + } + + /** Process a settled attempt, then reserve at most one next round. */ + async function drive(state: DriverState): Promise { + const { agent } = state + if (!readyToDrive(state)) return + + if (state.needsCheckpoint) { + state.needsCheckpoint = false + try { + await ctx.sessions.flush(agent.session) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: durability checkpoint failed for agent "${agent.id}": ${renderThrown(error)}`) + const goal = currentGoal(state) + if (goal !== undefined) applyOutcome(state, goal, { kind: 'disarm', reason: 'durability-failed' }) + return + } + // A mutation or ordinary prompt may have arrived while the checkpoint + // was settling. Give it its own checkpoint / turn before reserving. + if (!readyAfterCheckpoint(state)) return + } + + const attempt = state.attempt + if (attempt !== undefined) { + if (attempt.reason === undefined) return + state.attempt = undefined + const turn = attempt.turn + /* v8 ignore next -- a closed attempt acquired its turn at turn/start */ + if (turn === undefined) throw new Error('settled goal-round attempt lacks a turn') + const durable = !state.flushFailedTurns.delete(turn) + const goal = currentGoal(state) + if (goal !== undefined && goal.id === attempt.goalId && goal.revision === attempt.revision + && goal.phase === 'active' && goal.activation === 'armed') { + const outcome = attempt.phase === 'queued' && attempt.rejectedReason !== undefined && !attempt.stale + ? { kind: 'blocked', code: 'prompt-rejected', message: attempt.rejectedReason } as const + : classifyGoalRound(attempt.reason, durable) + if (!attempt.stale) applyOutcome(state, goal, outcome) + } + if (!readyToDrive(state)) return + } + + const goal = currentGoal(state) + if (goal === undefined || goal.phase !== 'active' || goal.activation !== 'armed') return + if (goal.roundsStarted >= goal.maxGoalRounds) { + ctx.goals.block(agent, goalRef(goal), { + code: 'round-limit', + message: `Goal reached its configured limit of ${goal.maxGoalRounds} rounds.`, + }) + return + } + + const round = goal.roundsStarted + 1 + const content = renderGoalRoundPrompt(goal, round) + const reservation: RoundAttempt = { + goalId: goal.id, + revision: goal.revision, + round, + content, + phase: 'queued', + turn: undefined, + reason: undefined, + rejectedReason: undefined, + stale: false, + } + state.attempt = reservation + try { + agent.send(content, { + source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round }, + }) + } catch (error: unknown) { + state.attempt = undefined + ctx.logger.warn(`goal-session: could not queue round ${round} for agent "${agent.id}": ${renderThrown(error)}`) + const latest = currentGoal(state) + if (latest !== undefined && latest.id === goal.id && latest.revision === goal.revision + && latest.phase === 'active' && latest.activation === 'armed') { + ctx.goals.block(agent, goalRef(latest), { + code: 'queue-failed', + message: `Could not queue goal round ${round}: ${renderThrown(error)}`, + }) + } + } + } + + /** Coalesce triggers onto one agent-local serialized driver. */ + function requestDrive(state: DriverState): void { + /* v8 ignore next -- teardown may race a final trigger after synchronously closing admission */ + if (state.stopping) return + state.requested = true + if (state.run !== undefined) return + let run: Promise + try { + run = ctx.agents.withoutInitiator(async () => { + while (state.requested && !state.stopping) { + state.requested = false + try { + await drive(state) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: driver failed for agent "${state.agent.id}": ${renderThrown(error)}`) + disarm(state) + } + } + }) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: could not start driver for agent "${state.agent.id}": ${renderThrown(error)}`) + disarm(state) + return + } + state.run = run + const retire = (): void => { + state.run = undefined + if (state.requested && !state.stopping) requestDrive(state) + } + void run.then(retire, (error: unknown) => { + ctx.logger.warn(`goal-session: driver task rejected for agent "${state.agent.id}": ${renderThrown(error)}`) + disarm(state) + retire() + }) + } + + // One composite effect owns every listener and the quiescent close. Cordis + // unloads sibling effects concurrently; nesting makes the close run first + // and keeps the admission fence installed until its drain settles. + ctx.effect(function* () { + /** Mark a post-turn persistence failure before idle scheduling can run. */ + ctx.on('agent/error', (agent, turn) => { + const state = stateFor(agent) + const closed = agent.session.events.some(event => event.type === 'turn/end' && event.data.turn === turn) + if (!closed) return + if (state.attempt?.turn === turn) state.flushFailedTurns.add(turn) + disarm(state) + }) + + ctx.on('agent/created', (agent) => { stateFor(agent) }) + ctx.on('agent/disposed', (agent) => { states.delete(agent) }) + ctx.on('agent/session-start', (agent) => { + const state = stateFor(agent) + state.attempt = undefined + state.openTurn = undefined + state.competingQueued = false + state.needsCheckpoint = false + state.flushFailedTurns.clear() + }) + ctx.on('agent/status', (agent, status) => { + const state = stateFor(agent) + if (status === 'disposed') { + state.stopping = true + return + } + if (status === 'idle') { + state.competingQueued = false + requestDrive(state) + } + }) + ctx.on('agent/queued', (agent, content, info) => { + const state = stateFor(agent) + const attempt = state.attempt + if (attempt !== undefined && sameQueued(content, info.source, attempt)) return + state.competingQueued = true + if (attempt?.phase === 'queued') attempt.stale = true + }) + ctx.on('agent/cancel-requested', (agent, reason) => { + const state = stateFor(agent) + const attempt = state.attempt + state.attempt = undefined + state.competingQueued = false + const goal = currentGoal(state) + if (goal?.phase === 'active' && goal.activation === 'armed') { + if (attempt === undefined) { + disarm(state) + return + } + try { + applyOutcome(state, goal, { kind: 'pause', reason }) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`) + disarm(state) + } + } + }) + ctx.on('goal/changed', (agent) => { + const state = stateFor(agent) + state.needsCheckpoint = true + requestDrive(state) + }) + + ctx.on('session/event', (session: Session, event: SessionEvent) => { + const agent = ctx.agents.get(session.id) + if (agent === undefined || agent.session !== session) return + const state = stateFor(agent) + switch (event.type) { + case 'turn/start': + state.openTurn = event.data.turn + if (state.attempt !== undefined && isGoalRoundSource(event.data.trigger.source) + && sameRound(event.data.trigger.source, state.attempt)) { + state.attempt.turn = event.data.turn + } + return + case 'user/message': + if (state.attempt !== undefined && isGoalRoundSource(event.data.source) + && sameRound(event.data.source, state.attempt)) { + state.attempt.phase = 'admitted' + /* v8 ignore next -- this driver's admitted message always follows its observed turn/start */ + if (state.openTurn !== undefined) state.attempt.turn = state.openTurn + } + return + case 'prompt/blocked': + if (state.attempt !== undefined && state.attempt.phase === 'queued' + && isGoalRoundSource(event.data.source) && sameRound(event.data.source, state.attempt)) { + /* v8 ignore next -- this driver's rejected message always follows its observed turn/start */ + if (state.openTurn !== undefined) state.attempt.turn = state.openTurn + state.attempt.rejectedReason = event.data.reason + if (event.data.reason === STALE_ROUND_REASON) state.attempt.stale = true + } + return + case 'turn/end': + if (state.attempt?.turn === event.data.turn) state.attempt.reason = event.data.reason + /* v8 ignore next -- balanced live turns close the open turn just observed by this listener */ + if (state.openTurn === event.data.turn) state.openTurn = undefined + return + default: + return + } + }) + + /** Fail closed unless the queued prompt still owns the exact live revision. */ + function validReservation( + state: DriverState, + content: ContentBlock[], + source: GoalMessageSource, + ): boolean { + const attempt = state.attempt + const goal = currentGoal(state) + return ctx.fiber.state === FiberState.ACTIVE + && !state.stopping && attempt !== undefined && attempt.phase === 'queued' + && !attempt.stale && sameQueued(content, source, attempt) + && goal !== undefined && goal.id === source.goalId && goal.revision === source.revision + && goal.phase === 'active' && goal.activation === 'armed' + && source.round === goal.roundsStarted + 1 + } + + ctx.on('agent/prompt-submit', async (agent, content, source, next): Promise => { + if (!isGoalRoundSource(source)) return next() + const state = stateFor(agent) + let valid = false + try { + valid = validReservation(state, content, source) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: admission check failed for agent "${agent.id}": ${renderThrown(error)}`) + disarm(state) + } + if (!valid) { + const attempt = state.attempt + if (attempt !== undefined && sameRound(source, attempt)) attempt.stale = true + return { kind: 'block', reason: STALE_ROUND_REASON } + } + const decision = await next() + if (decision.kind === 'block') return decision + try { + valid = validReservation(state, content, source) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: post-admission check failed for agent "${agent.id}": ${renderThrown(error)}`) + disarm(state) + valid = false + } + if (!valid) { + const attempt = state.attempt + if (attempt !== undefined && sameRound(source, attempt)) attempt.stale = true + return { kind: 'block', reason: STALE_ROUND_REASON } + } + return decision + }) + + // Loading a lifecycle driver over existing agents never inherits hidden + // automatic authority from an earlier producer instance. + for (const agent of ctx.agents.list()) { + const state = stateFor(agent) + disarm(state) + } + + // Yielded after listener registration, so this close runs first and the + // composite effect removes listeners only after its promise settles. + yield async () => { + const waits: Promise[] = [] + for (const state of states.values()) { + state.stopping = true + disarm(state) + const attempt = state.attempt + if (attempt !== undefined) { + attempt.stale = true + if (attempt.phase === 'admitted' && state.agent.status === 'running') { + state.agent.cancel('goal-session driver disposed') + } + waits.push(state.agent.whenIdle()) + } + if (state.run !== undefined) waits.push(state.run) + } + await Promise.allSettled(waits) + states.clear() + } + }, 'goal-session lifecycle') +} diff --git a/packages/goal/goal-session/src/invariant.ts b/packages/goal/goal-session/src/invariant.ts new file mode 100644 index 0000000000..53cdd9b20b --- /dev/null +++ b/packages/goal/goal-session/src/invariant.ts @@ -0,0 +1,84 @@ +/** Package-owned goal-round prompt invariants. @module @deepseek-ai/dsh-goal-session/invariant */ + +import { isDeepStrictEqual } from 'node:util' +import type { Context } from 'cordis' +import { foldGoal, type FoldedGoal, type GoalMessageSource, type GoalView } from '@deepseek-ai/dsh-goal' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { renderGoalRoundPrompt } from './prompt.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-goal-session' + +/** Cordis companion plugin name. */ +export const name = 'goal-session-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** Attribute strict goal-fold failures to this companion's reconstruction. */ +function foldChecked(events: readonly SessionEvent[], fail: InvariantFailure): FoldedGoal { + try { + return foldGoal(events) + } catch (error: unknown) { + /* v8 ignore next -- the strict goal decoder throws Error instances */ + const message = error instanceof Error ? error.message : String(error) + return fail(`cannot reconstruct the goal before a continuation message: ${message}`) + } +} + +/** Recreate the live-shaped view consumed by the package's pure prompt renderer. */ +function goalView(folded: FoldedGoal, source: GoalMessageSource, fail: InvariantFailure): GoalView { + const goal = folded.goal + if (goal === undefined || folded.createdAt === undefined || folded.updatedAt === undefined + || goal.phase !== 'active' || goal.id !== source.goalId || goal.revision !== source.revision + || source.round !== folded.roundsStarted + 1 || source.round > goal.maxGoalRounds) { + return fail(`goal round ${source.round} cannot be reconstructed from the preceding durable goal state`) + } + return { + ...goal, + roundsStarted: folded.roundsStarted, + createdAt: folded.createdAt, + updatedAt: folded.updatedAt, + activation: 'armed', + } +} + +/** Validate one package-owned continuation message against its durable prefix. */ +function validateEvent( + prior: readonly SessionEvent[], + event: SessionEvent, + fail: InvariantFailure, +): void { + if (event.type !== 'user/message') return + const source = event.data.source + if (source.kind !== 'goal' || source.round <= 0) return + const expected = renderGoalRoundPrompt(goalView(foldChecked(prior, fail), source, fail), source.round) + if (!isDeepStrictEqual(event.data.content, expected)) { + fail(`goal round ${source.round} content does not match the package-owned continuation prompt`) + } +} + +/** Check existing sessions and every candidate event before Session publishes it. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const session of ctx.sessions.list()) { + const prior: SessionEvent[] = [] + for (const event of session.events) { + validateEvent(prior, event, fail) + prior.push(event) + } + } + /* jscpd:ignore-start -- package companions share dispatch and registration plumbing */ + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + validateEvent(session.events, event, fail) + }, { global: true }) +}, { inject: ['sessions'] }) + +/** + * Register the goal-session invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/goal/goal-session/src/outcome.ts b/packages/goal/goal-session/src/outcome.ts new file mode 100644 index 0000000000..e69f53eeec --- /dev/null +++ b/packages/goal/goal-session/src/outcome.ts @@ -0,0 +1,53 @@ +/** Typed settlement policy for one admitted same-session goal round. */ + +import type { TurnEndReason } from '@deepseek-ai/dsh-session' + +/** Driver action derived from one closed goal-owned turn. */ +export type GoalRoundOutcome = + | { readonly kind: 'continue' } + | { readonly kind: 'pause'; readonly reason: string } + | { + readonly kind: 'blocked' + readonly code: 'usage-limited' | 'turn-error' | 'max-tokens' | 'prompt-rejected' | 'unknown-turn-outcome' + readonly message: string + } + | { readonly kind: 'disarm'; readonly reason: 'durability-failed' | 'disposed' | 'interrupted' } + +/** + * Classify one closed goal round without mutating goal state. + * @param reason - durable reason from the round's `turn/end`. + * @param durable - whether the closing flush reached its durability checkpoint. + * @returns the single driver action; no abnormal outcome requests an automatic retry. + */ +export function classifyGoalRound(reason: TurnEndReason, durable: boolean): GoalRoundOutcome { + if (!durable) return { kind: 'disarm', reason: 'durability-failed' } + const extensibleReason: { readonly kind: string } = reason + switch (reason.kind) { + case 'completed': + return { kind: 'continue' } + case 'aborted': + return { kind: 'pause', reason: reason.reason ?? 'cancelled' } + case 'error': { + const { code, message } = reason.failure ?? reason + return code === 'RATE_LIMIT' || code === 'QUOTA' + ? { kind: 'blocked', code: 'usage-limited', message } + : { kind: 'blocked', code: 'turn-error', message } + } + case 'max-tokens': + return { kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' } + case 'rejected': + return { kind: 'blocked', code: 'prompt-rejected', message: reason.reason } + case 'disposed': + return { kind: 'disarm', reason: 'disposed' } + case 'interrupted': + return { kind: 'disarm', reason: 'interrupted' } + // TurnEndReason is merge-extensible. An unknown producer cannot opt into + // automatic retry merely by adding a tag; stop for inspection instead. + default: + return { + kind: 'blocked', + code: 'unknown-turn-outcome', + message: `unknown turn outcome: ${extensibleReason.kind}`, + } + } +} diff --git a/packages/goal/goal-session/src/prompt.ts b/packages/goal/goal-session/src/prompt.ts new file mode 100644 index 0000000000..9a2f69fcd8 --- /dev/null +++ b/packages/goal/goal-session/src/prompt.ts @@ -0,0 +1,26 @@ +/** Model-visible continuation prompt for one same-session goal round. */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { GoalView } from '@deepseek-ai/dsh-goal' + +/** + * Render the complete goal-round instruction retained in session history. + * @param goal - exact active goal revision being admitted. + * @param round - next positive round number. + * @returns a fresh one-block prompt for `Agent.send()`. + */ +export function renderGoalRoundPrompt(goal: GoalView, round: number): ContentBlock[] { + return [{ + type: 'text', + text: '\n' + + `Objective: ${JSON.stringify(goal.objective)}\n` + + `Round: ${round}/${goal.maxGoalRounds}\n\n` + + 'Continue working toward the objective in this same session. Treat the current workspace, ' + + 'tool results, and durable session state as authoritative; inspect them instead of assuming ' + + 'earlier narration is still current. Make concrete progress and verify the result. Before ' + + 'claiming completion, gather evidence that the whole objective is achieved, read the current ' + + 'goal, and mark it complete. If work remains, leave the goal active for the next round. Follow ' + + 'the configured goal-tool policy before reporting a blocker.\n' + + '', + }] +} diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts new file mode 100644 index 0000000000..8495b73ecc --- /dev/null +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -0,0 +1,707 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' +import type { GoalView } from '@deepseek-ai/dsh-goal' +import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { TurnEndReason } from '@deepseek-ai/dsh-session' +import * as goalSession from '../src/index.ts' + +type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[]) + +/** Small request-recording adapter with controllable failure and cancellation. */ +class ScriptedAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + constructor(private readonly script: ScriptEntry[]) { + super() + } + + override async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + const entry = this.script.shift() + if (entry === undefined) throw new Error('ScriptedAdapter: script exhausted') + if (entry instanceof Error) throw entry + if (entry === 'hang') { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'partial' } + await new Promise((_resolve, reject) => { + if (options.signal?.aborted) { + reject(new Error('aborted')) + return + } + options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }) + return + } + const chunks = typeof entry === 'function' ? entry(options) : entry + for (const chunk of chunks) yield chunk + } +} + +/** One successful text response. */ +function textResponse(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +/** One successful response cut off at the model output limit. */ +function maxTokensResponse(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'finish', reason: { kind: 'max-tokens' } }, + ] +} + +/** Complete request history as a single string for ordering assertions. */ +function requestText(request: GenerateOptions): string { + return request.messages + .flatMap(message => message.content) + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') +} + +interface Harness { + readonly ctx: Context + readonly adapter: ScriptedAdapter + readonly agent: Agent + readonly driver: Awaited> +} + +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.allSettled(contexts.splice(0).map(context => context.fiber.dispose())) +}) + +/** Mount a real loop with only its model scripted. */ +async function harness(script: ScriptEntry[]): Promise { + const ctx = new Context() + contexts.push(ctx) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(GoalService) + const driver = await ctx.plugin(goalSession) + await ctx.plugin(AgentLoop, { agents: [] }) + const adapter = new ScriptedAdapter(script) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(SessionId(`goal-session-${Math.random()}`), { + provider: 'mock', + model: 'mock', + }) + return { ctx, adapter, agent, driver } +} + +/** Await a stable goal projection selected by the caller. */ +async function waitForGoal( + ctx: Context, + agent: Agent, + predicate: (goal: GoalView | undefined) => boolean, +): Promise { + await vi.waitFor(() => { + expect(predicate(ctx.goals.get(agent))).toBe(true) + }) + return ctx.goals.get(agent) +} + +/** Await a specific number of dispatched model requests. */ +async function waitForRequests(adapter: ScriptedAdapter, count: number): Promise { + await vi.waitFor(() => { + expect(adapter.requests).toHaveLength(count) + }) +} + +describe('goal-round outcome policy', () => { + it.each([ + [{ kind: 'completed' }, true, { kind: 'continue' }], + [{ kind: 'aborted', reason: 'operator stopped' }, true, { kind: 'pause', reason: 'operator stopped' }], + [{ kind: 'aborted' }, true, { kind: 'pause', reason: 'cancelled' }], + [{ kind: 'error', step: 1, message: 'slow down', code: 'RATE_LIMIT' }, true, + { kind: 'blocked', code: 'usage-limited', message: 'slow down' }], + [{ kind: 'error', step: 1, failure: { message: 'credits exhausted', code: 'QUOTA' } }, true, + { kind: 'blocked', code: 'usage-limited', message: 'credits exhausted' }], + [{ kind: 'error', step: 1, failure: { message: 'provider failed', code: 'SERVER' } }, true, + { kind: 'blocked', code: 'turn-error', message: 'provider failed' }], + [{ kind: 'error', step: 1, message: 'broken' }, true, + { kind: 'blocked', code: 'turn-error', message: 'broken' }], + [{ kind: 'max-tokens' }, true, + { kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' }], + [{ kind: 'rejected', reason: 'policy' }, true, + { kind: 'blocked', code: 'prompt-rejected', message: 'policy' }], + [{ kind: 'disposed' }, true, { kind: 'disarm', reason: 'disposed' }], + [{ kind: 'interrupted' }, true, { kind: 'disarm', reason: 'interrupted' }], + [{ kind: 'completed' }, false, { kind: 'disarm', reason: 'durability-failed' }], + [{ kind: 'future-outcome' } as unknown as TurnEndReason, true, + { kind: 'blocked', code: 'unknown-turn-outcome', message: 'unknown turn outcome: future-outcome' }], + ] as const)('maps %j without abnormal automatic retry', (reason, durable, expected) => { + expect(goalSession.classifyGoalRound(reason, durable)).toEqual(expected) + }) + + it('renders the objective, round budget, authority boundary, and completion protocol', () => { + const goal: GoalView = { + id: GoalId('goal-prompt'), + revision: 4, + objective: 'Ship verified support', + phase: 'active', + maxGoalRounds: 9, + roundsStarted: 2, + createdAt: 1, + updatedAt: 2, + activation: 'armed', + } + const prompt = goalSession.renderGoalRoundPrompt(goal, 3) + expect(prompt).toHaveLength(1) + const block = prompt[0] + if (block?.type !== 'text') throw new Error('expected a text goal-round prompt') + expect(block.text).toMatch( + /\nObjective: "Ship verified support"\nRound: 3\/9[\s\S]*current workspace[\s\S]*verify[\s\S]*mark it complete/, + ) + }) + + it('quotes multiline or tag-like objective text as one unambiguous data value', () => { + const goal: GoalView = { + id: GoalId('goal-escaped-prompt'), + revision: 1, + objective: 'first line\n second line', + phase: 'active', + maxGoalRounds: 2, + roundsStarted: 0, + createdAt: 1, + updatedAt: 1, + activation: 'armed', + } + const block = goalSession.renderGoalRoundPrompt(goal, 1)[0] + if (block?.type !== 'text') throw new Error('expected a text goal-round prompt') + expect(block.text).toContain('Objective: "first line\\n second line"') + expect(block.text.match(/\n<\/goal_round>/g)).toHaveLength(1) + }) +}) + +describe('same-session goal driving', () => { + it('admits exact numbered rounds until the durable round cap', async () => { + const test = await harness([textResponse('round one'), textResponse('round two')]) + const created = test.ctx.goals.create(test.agent, { objective: 'finish twice', maxGoalRounds: 2 }) + + const final = await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') + + expect(final).toMatchObject({ id: created.id, roundsStarted: 2, activation: 'disarmed' }) + expect(final?.blockedReason).toEqual({ + code: 'round-limit', + message: 'Goal reached its configured limit of 2 rounds.', + }) + expect(test.adapter.requests).toHaveLength(2) + const rounds: number[] = [] + for (const event of test.agent.session.events) { + if (event.type === 'user/message' && event.data.source.kind === 'goal') { + rounds.push(event.data.source.round) + } + } + expect(rounds).toEqual([1, 2]) + expect(requestText(test.adapter.requests[0]!)).toContain('Round: 1/2') + expect(requestText(test.adapter.requests[1]!)).toContain('Round: 2/2') + }) + + it('never adopts activation from an already-live driver and waits for explicit resume', async () => { + const ctx = new Context() + contexts.push(ctx) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(GoalService) + await ctx.plugin(AgentLoop, { agents: [] }) + const adapter = new ScriptedAdapter([textResponse('after resume')]) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(SessionId('goal-session-hot-load'), { provider: 'mock', model: 'mock' }) + const created = ctx.goals.create(agent, { objective: 'wait for a human', maxGoalRounds: 1 }) + + await ctx.plugin(goalSession) + await Promise.resolve() + expect(ctx.goals.get(agent)).toMatchObject({ phase: 'active', activation: 'disarmed', revision: 1 }) + expect(adapter.requests).toHaveLength(0) + + ctx.goals.resume(agent, created) + await waitForGoal(ctx, agent, goal => goal?.phase === 'blocked') + expect(adapter.requests).toHaveLength(1) + }) + + it.each([ + ['rate limit', new LlmError('slow down', 'RATE_LIMIT'), 'usage-limited'], + ['request error', new Error('provider broke'), 'turn-error'], + ['max tokens', maxTokensResponse('unfinished'), 'max-tokens'], + ] as const)('stops after a %s without an automatic retry', async (_label, response, code) => { + const test = await harness([response]) + test.ctx.goals.create(test.agent, { objective: 'stop safely', maxGoalRounds: 8 }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') + + expect(goal).toMatchObject({ roundsStarted: 1, activation: 'disarmed' }) + expect(goal?.blockedReason?.code).toBe(code) + expect(test.adapter.requests).toHaveLength(1) + }) + + it('maps a downstream prompt veto to blocked without admitting the round', async () => { + const test = await harness([]) + test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => source.kind === 'goal' + ? Promise.resolve({ kind: 'block', reason: 'deployment policy' }) + : next()) + test.ctx.goals.create(test.agent, { objective: 'respect policy' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') + + expect(goal?.roundsStarted).toBe(0) + expect(goal?.blockedReason).toEqual({ code: 'prompt-rejected', message: 'deployment policy' }) + expect(test.adapter.requests).toHaveLength(0) + expect(test.agent.session.events.some(event => event.type === 'prompt/blocked' + && event.data.reason === 'deployment policy')).toBe(true) + }) + + it('does not reserve again when a stopped-goal observer queues ordinary work', async () => { + const test = await harness([textResponse('human follow-up')]) + test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => source.kind === 'goal' + ? Promise.resolve({ kind: 'block', reason: 'stop this round' }) + : next()) + test.ctx.on('goal/changed', (agent, change) => { + if (change.operation === 'block') agent.send([{ type: 'text', text: 'inspect the blocker' }]) + }) + test.ctx.goals.create(test.agent, { objective: 'stop and inspect' }) + + await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') + await waitForRequests(test.adapter, 1) + await test.agent.whenIdle() + + expect(requestText(test.adapter.requests[0]!)).toContain('inspect the blocker') + }) + + it('pauses and drops a reserved round when cancellation lands before admission', async () => { + const test = await harness([]) + const cancel = test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent === test.agent && info.source.kind === 'goal') { + cancel() + agent.cancel('operator cancelled pending goal') + } + }) + test.ctx.goals.create(test.agent, { objective: 'do not start yet' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused') + + expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' }) + expect(test.adapter.requests).toHaveLength(0) + expect(test.agent.session.events.some(event => event.type === 'user/message' + && event.data.source.kind === 'goal')).toBe(false) + }) + + it('pauses an admitted round when cancellation aborts an active step', async () => { + const test = await harness(['hang']) + test.ctx.goals.create(test.agent, { objective: 'stop in flight' }) + await waitForRequests(test.adapter, 1) + + test.agent.cancel('operator stopped active goal') + await test.agent.whenIdle() + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused') + + expect(goal).toMatchObject({ roundsStarted: 1, activation: 'disarmed' }) + expect(test.adapter.requests).toHaveLength(1) + }) + + it('lets already-queued human work finish before reserving the next round', async () => { + const test = await harness([textResponse('human answer'), textResponse('goal answer')]) + test.ctx.goals.create(test.agent, { objective: 'continue after the human', maxGoalRounds: 1 }) + test.agent.send([{ type: 'text', text: 'human goes first' }]) + + await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') + + expect(test.adapter.requests).toHaveLength(2) + expect(requestText(test.adapter.requests[0]!)).toContain('human goes first') + expect(requestText(test.adapter.requests[0]!)).not.toContain('') + expect(requestText(test.adapter.requests[1]!)).toContain('') + }) + + it('makes a reserved round stale when a listener queues human work behind it', async () => { + const test = await harness([textResponse('human batch'), textResponse('later goal')]) + let inserted = false + test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return + inserted = true + agent.send([{ type: 'text', text: 'human joined the pending batch' }]) + }) + test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 }) + + await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') + + expect(test.adapter.requests).toHaveLength(2) + expect(requestText(test.adapter.requests[0]!)).toContain('human joined the pending batch') + expect(requestText(test.adapter.requests[0]!)).not.toContain('') + expect(requestText(test.adapter.requests[1]!)).toContain('') + }) + + it('blocks a queued reservation made stale by a goal edit and continues the new revision', async () => { + const test = await harness([textResponse('new revision')]) + let edited = false + test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent !== test.agent || info.source.kind !== 'goal' || edited) return + edited = true + const current = test.ctx.goals.get(agent) + if (current === undefined) throw new Error('missing goal during queued edit') + test.ctx.goals.edit(agent, current, { objective: 'new objective' }) + }) + test.ctx.goals.create(test.agent, { objective: 'old objective', maxGoalRounds: 1 }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') + + expect(goal).toMatchObject({ revision: 3, objective: 'new objective', roundsStarted: 1 }) + const blocked = test.agent.session.events.find(event => event.type === 'prompt/blocked') + expect(blocked?.type === 'prompt/blocked' ? blocked.data.reason : undefined) + .toBe('stale goal-round reservation') + const admitted = test.agent.session.events.find(event => event.type === 'user/message' + && event.data.source.kind === 'goal') + expect(admitted?.type === 'user/message' && admitted.data.source.kind === 'goal' + ? admitted.data.source.revision + : undefined).toBe(2) + }) + + it('rechecks revision after downstream prompt hooks before admitting', async () => { + const test = await harness([textResponse('new revision')]) + let edited = false + test.ctx.on('agent/prompt-submit', (agent, _content, source, next) => { + if (source.kind === 'goal' && !edited) { + edited = true + const current = test.ctx.goals.get(agent) + if (current === undefined) throw new Error('missing goal during prompt edit') + test.ctx.goals.edit(agent, current, { objective: 'edited downstream' }) + } + return next() + }) + test.ctx.goals.create(test.agent, { objective: 'edit during admission', maxGoalRounds: 1 }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') + + expect(goal).toMatchObject({ objective: 'edited downstream', roundsStarted: 1 }) + expect(test.adapter.requests).toHaveLength(1) + expect(test.agent.session.events.some(event => event.type === 'prompt/blocked' + && event.data.reason === 'stale goal-round reservation')).toBe(true) + }) + + it('disarms without dispatch when a durability checkpoint fails', async () => { + const test = await harness([]) + test.ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable'))) + test.ctx.goals.create(test.agent, { objective: 'do not outrun storage' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal).toMatchObject({ phase: 'active', roundsStarted: 0 }) + expect(test.adapter.requests).toHaveLength(0) + }) + + it('contains a checkpoint failure after a clear notification leaves no current goal', async () => { + const test = await harness([]) + test.ctx.on('session/flush', () => Promise.reject(new Error('clear checkpoint failed'))) + agentEvents(test.ctx, test.agent).emit('goal/changed', { + operation: 'clear', + ref: { id: GoalId('cleared-goal'), revision: 2 }, + }) + await new Promise((resolve) => { setImmediate(resolve) }) + + expect(test.ctx.goals.get(test.agent)).toBeUndefined() + expect(test.adapter.requests).toHaveLength(0) + }) + + it('disarms an admitted round when a later injection hides its failed closing checkpoint', async () => { + const test = await harness([textResponse('not durable')]) + let injected = false + test.ctx.on('session/flush', (session) => { + const lastStart = session.events.findLast(event => event.type === 'turn/start') + if (lastStart?.type === 'turn/start' && lastStart.data.trigger.kind === 'message' + && lastStart.data.trigger.source.kind === 'goal' && !injected) { + injected = true + test.agent.inject([{ type: 'text', text: 'concurrent completion notice' }], { + source: { kind: 'plugin', plugin: 'test' }, + }) + return Promise.reject(new Error('round flush failed')) + } + }) + test.ctx.goals.create(test.agent, { objective: 'checkpoint the result' }) + + const goal = await waitForGoal( + test.ctx, + test.agent, + current => current?.roundsStarted === 1 && current.activation === 'disarmed', + ) + + expect(goal?.phase).toBe('active') + expect(test.adapter.requests).toHaveLength(1) + const turns = test.agent.session.events.filter(event => event.type === 'turn/start') + const goalTurn = turns.findIndex(event => event.data.trigger.source.kind === 'goal') + const injectedTurn = turns.findIndex(event => event.data.trigger.source.kind === 'plugin') + expect(injectedTurn).toBeGreaterThan(goalTurn) + }) + + it('blocks the goal when a custom agent rejects the otherwise valid send', async () => { + const test = await harness([]) + vi.spyOn(test.agent, 'send').mockImplementationOnce(() => { + throw new Error('queue rejected') + }) + test.ctx.goals.create(test.agent, { objective: 'handle queue failure' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') + + expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' }) + expect(goal?.blockedReason).toEqual({ + code: 'queue-failed', + message: 'Could not queue goal round 1: queue rejected', + }) + expect(test.adapter.requests).toHaveLength(0) + }) + + it('preserves a custom agent side effect when send disarms before throwing', async () => { + const test = await harness([]) + vi.spyOn(test.agent, 'send').mockImplementationOnce(() => { + test.ctx.goals.disarm(test.agent) + throw new Error('queue rejected after disarm') + }) + test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal).toMatchObject({ phase: 'active', roundsStarted: 0 }) + expect(test.adapter.requests).toHaveLength(0) + }) + + it('contains a driver read failure and removes continuation authority', async () => { + const test = await harness([]) + let flushes = 0 + test.ctx.on('session/flush', () => { + flushes += 1 + if (flushes !== 2) return + vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { + throw new Error('corrupt projection') + }) + }) + test.ctx.goals.create(test.agent, { objective: 'fail the driver closed' }) + await new Promise((resolve) => { setImmediate(resolve) }) + + const goal = test.ctx.goals.get(test.agent) + + expect(goal?.phase).toBe('active') + expect(test.adapter.requests).toHaveLength(0) + }) + + it('contains synchronous scheduler startup failure', async () => { + const test = await harness([]) + vi.spyOn(test.ctx.agents, 'withoutInitiator').mockImplementationOnce(() => { + throw 'scheduler closed' + }) + test.ctx.goals.create(test.agent, { objective: 'fail startup closed' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal?.phase).toBe('active') + expect(test.adapter.requests).toHaveLength(0) + }) + + it('contains an asynchronously rejected scheduler task', async () => { + const test = await harness([]) + vi.spyOn(test.ctx.agents, 'withoutInitiator').mockImplementationOnce( + () => Promise.reject(new Error('scheduler task rejected')), + ) + test.ctx.goals.create(test.agent, { objective: 'fail task closed' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal?.phase).toBe('active') + expect(test.adapter.requests).toHaveLength(0) + }) + + it('fails a pre-admission read closed even when the first disarm attempt throws', async () => { + const test = await harness([textResponse('retry after containment')]) + let armed = true + test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent !== test.agent || info.source.kind !== 'goal' || !armed) return + armed = false + vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { + throw new Error('admission projection failed') + }) + vi.spyOn(test.ctx.goals, 'disarm').mockImplementationOnce(() => { + throw 'disarm failed' + }) + }) + test.ctx.goals.create(test.agent, { objective: 'retry stale admission', maxGoalRounds: 1 }) + + await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') + + expect(test.adapter.requests).toHaveLength(1) + expect(test.agent.session.events.some(event => event.type === 'prompt/blocked' + && event.data.reason === 'stale goal-round reservation')).toBe(true) + }) + + it('fails a post-hook read closed before the prompt can enter history', async () => { + const test = await harness([]) + let armed = true + test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => { + if (source.kind === 'goal' && armed) { + armed = false + vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { + throw new Error('post-hook projection failed') + }) + } + return next() + }) + test.ctx.goals.create(test.agent, { objective: 'block post-hook failure' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal).toMatchObject({ phase: 'active', roundsStarted: 0 }) + expect(test.adapter.requests).toHaveLength(0) + }) + + it('blocks forged goal attribution without touching an absent reservation', async () => { + const test = await harness([]) + test.agent.send([{ type: 'text', text: 'forged automatic work' }], { + source: { kind: 'goal', goalId: GoalId('forged-goal'), revision: 1, round: 1 }, + }) + await test.agent.whenIdle() + + expect(test.adapter.requests).toHaveLength(0) + expect(test.agent.session.events.some(event => event.type === 'prompt/blocked' + && event.data.reason === 'stale goal-round reservation')).toBe(true) + }) + + it('does not invent goal state when ordinary queued work is cancelled', async () => { + const test = await harness([]) + test.agent.send([{ type: 'text', text: 'cancel ordinary work' }]) + test.agent.cancel('ordinary cancellation') + await test.agent.whenIdle() + + expect(test.ctx.goals.get(test.agent)).toBeUndefined() + expect(test.adapter.requests).toHaveLength(0) + }) + + it('disarms without durably pausing when cancellation belongs to unrelated human work', async () => { + const test = await harness(['hang']) + test.agent.send([{ type: 'text', text: 'inspect something first' }]) + await waitForRequests(test.adapter, 1) + const created = test.ctx.goals.create(test.agent, { objective: 'continue after inspection' }) + + test.agent.cancel('cancel the inspection') + await test.agent.whenIdle() + + expect(test.ctx.goals.get(test.agent)).toMatchObject({ + id: created.id, + revision: created.revision, + phase: 'active', + activation: 'disarmed', + roundsStarted: 0, + }) + }) + + it('falls back to disarming when a cancelled reservation cannot be paused', async () => { + const test = await harness([]) + const cancel = test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent !== test.agent || info.source.kind !== 'goal') return + cancel() + vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => { + throw new Error('pause failed') + }) + agent.cancel('cancel the reserved goal round') + }) + test.ctx.goals.create(test.agent, { objective: 'fail closed after cancellation' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal).toMatchObject({ phase: 'active', revision: 1, roundsStarted: 0 }) + expect(test.adapter.requests).toHaveLength(0) + }) + + it('blocks admission when downstream cancellation clears the reservation', async () => { + const test = await harness([]) + let cancelled = false + test.ctx.on('agent/prompt-submit', (agent, _content, source, next) => { + if (source.kind === 'goal' && !cancelled) { + cancelled = true + agent.cancel('cancel from downstream admission policy') + } + return next() + }) + test.ctx.goals.create(test.agent, { objective: 'cancel during admission' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused') + await test.agent.whenIdle() + + expect(goal?.roundsStarted).toBe(0) + expect(test.adapter.requests).toHaveLength(0) + }) + + it('disarms and cancels an admitted round before driver teardown completes', async () => { + const test = await harness(['hang']) + test.ctx.goals.create(test.agent, { objective: 'survive plugin unload' }) + await waitForRequests(test.adapter, 1) + + await test.driver.dispose() + + expect(test.ctx.goals.get(test.agent)).toMatchObject({ + phase: 'active', + activation: 'disarmed', + roundsStarted: 1, + }) + await test.agent.whenIdle() + expect(test.adapter.requests).toHaveLength(1) + }) + + it('cancels an accepted queued round and awaits its driver task during teardown', async () => { + const test = await harness([]) + let unloading: Promise | undefined + test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent === test.agent && info.source.kind === 'goal' && unloading === undefined) { + unloading = Promise.resolve(test.driver.dispose()) + } + }) + test.ctx.goals.create(test.agent, { objective: 'unload while queued' }) + await vi.waitFor(() => { expect(unloading).toBeDefined() }) + await unloading + + expect(test.ctx.goals.get(test.agent)).toMatchObject({ + phase: 'active', + activation: 'disarmed', + roundsStarted: 1, + }) + expect(test.adapter.requests).toHaveLength(1) + }) + + it('resets process-local scheduling state at a session-start edge', async () => { + const test = await harness([textResponse('after explicit resume')]) + const created = test.ctx.goals.create(test.agent, { objective: 'restart safely', maxGoalRounds: 1 }) + agentEvents(test.ctx, test.agent).emit('agent/session-start', 'resume') + await Promise.resolve() + + expect(test.ctx.goals.get(test.agent)).toMatchObject({ activation: 'disarmed', roundsStarted: 0 }) + expect(test.adapter.requests).toHaveLength(0) + + test.ctx.goals.resume(test.agent, created) + await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') + expect(test.adapter.requests).toHaveLength(1) + }) + + it('ignores session events without an exact owning agent and retires disposed agent state', async () => { + const test = await harness([]) + const orphan = test.ctx.sessions.create(SessionId('goal-session-orphan')) + orphan.append('turn/start', { + turn: 1, + trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } }, + }) + orphan.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const handle = await test.ctx.agents.create({ + sessionId: SessionId('goal-session-disposed'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + await handle.dispose() + + expect(test.ctx.agents.get(handle.agent.id)).toBeUndefined() + }) +}) diff --git a/packages/goal/goal-session/tests/invariant.spec.ts b/packages/goal/goal-session/tests/invariant.spec.ts new file mode 100644 index 0000000000..19200747e8 --- /dev/null +++ b/packages/goal/goal-session/tests/invariant.spec.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { + GoalId, + renderGoalChange, + type GoalSnapshotChangeMeta, + type GoalView, +} from '@deepseek-ai/dsh-goal' +import * as GoalSessionInvariant from '@deepseek-ai/dsh-goal-session/invariant' +import { renderGoalRoundPrompt } from '@deepseek-ai/dsh-goal-session' +import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' + +const change: GoalSnapshotChangeMeta = { + kind: 'goal/change', + version: 1, + operation: 'create', + goal: { + id: GoalId('goal-session-invariant'), + revision: 1, + objective: 'verify every continuation prompt', + phase: 'active', + maxGoalRounds: 2, + }, + roundsStarted: 0, + createdAt: 1, + updatedAt: 1, +} + +const changeSource = { + kind: 'goal', + goalId: change.goal.id, + revision: change.goal.revision, + round: 0, +} as const + +function view(roundsStarted: number): GoalView { + return { ...change.goal, roundsStarted, createdAt: 1, updatedAt: 1, activation: 'armed' } +} + +function appendChange(session: Session): void { + session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) + session.append('context/message', { + content: renderGoalChange(change), + source: changeSource, + meta: change as never, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) +} + +function appendRound(session: Session, turn: number, content = renderGoalRoundPrompt(view(turn - 2), turn - 1)): void { + const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: turn - 1 } as const + session.append('turn/start', { turn, trigger: { kind: 'message', source } }) + session.append('user/message', { content, source }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) +} + +async function mount(sessionFirst = false): Promise<{ ctx: Context; session: Session }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('goal-session-invariant')) + if (!sessionFirst) { + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(GoalSessionInvariant) + } + return { ctx, session } +} + +describe('goal-session prompt invariants', () => { + it('reconstructs existing rounds and accepts the next canonical prompt', async () => { + const { ctx, session } = await mount(true) + appendChange(session) + appendRound(session, 2) + + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(GoalSessionInvariant) + + expect(() => { appendRound(session, 3) }).not.toThrow() + ctx.sessions.create(SessionId('goal-session-invariant-dispatch')) + + const userSource = { kind: 'user' } as const + session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: userSource } }) + session.append('user/message', { + content: [{ type: 'text', text: 'ordinary human message' }], + source: userSource, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 4, reason: { kind: 'completed' } }) + + const stateSource = { ...changeSource, round: 0 } as const + session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: stateSource } }) + expect(() => { + session.append('user/message', { + content: [{ type: 'text', text: 'round zero is not a driver continuation' }], + source: stateSource, + }, { surfaceOp: 'append' }) + }).not.toThrow() + }) + + it('rejects a continuation whose content differs from the package renderer', async () => { + const { session } = await mount() + appendChange(session) + + expect(() => { + appendRound(session, 2, [{ type: 'text', text: 'counterfeit continuation' }]) + }).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-goal-session', + })) + }) + + it('rejects a goal round without a reconstructable active goal', async () => { + const { session } = await mount() + const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 } as const + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } }) + + expect(() => { + session.append('user/message', { + content: renderGoalRoundPrompt(view(0), 1), + source, + }, { surfaceOp: 'append' }) + }).toThrow(expect.objectContaining>({ + packageName: '@deepseek-ai/dsh-goal-session', + })) + }) + + it('attributes an invalid durable prefix during late loading', async () => { + const { ctx, session } = await mount(true) + session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) + session.append('context/message', { + content: [{ type: 'text', text: 'counterfeit goal state' }], + source: changeSource, + meta: change as never, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + appendRound(session, 2) + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(GoalSessionInvariant)).rejects.toMatchObject({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-goal-session', + }) + }) +}) diff --git a/packages/goal/goal-session/tsconfig.json b/packages/goal/goal-session/tsconfig.json new file mode 100644 index 0000000000..34ec1510e8 --- /dev/null +++ b/packages/goal/goal-session/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../goal" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index 07a1694b9e..e50044d135 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -15,7 +15,7 @@ Event-sourced same-session goal state. The service retains one current completio ## Service contract -`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). Creation default resolution is an internal implementation step, not an additional public verb. +`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). Creation default resolution is internal. `disarm()` is the lifecycle-only exception: it removes process-local continuation authority without writing a revision or emitting a mutation. At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation. @@ -23,7 +23,7 @@ Every non-clear mutation appends a complete versioned snapshot through `agent.in Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained. -Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. Session resume and fork therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation. +Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. A continuation driver also calls `disarm()` before unload or after durability uncertainty. Session resume, fork, and driver replacement therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation. The separately published `./invariant` companion maintains an independent fold of each attached session. It rejects malformed goal metadata, source or model-visible content drift, discontinuous revisions, illegal lifecycle transitions, timestamp regressions, and non-sequential admitted rounds before the candidate event enters the durable log. diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index dedfc385b6..7391c69e93 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -165,6 +165,21 @@ export class GoalService extends Service { return this.view(cache) } + /** + * Remove process-local continuation authority without changing durable goal + * phase or revision. Lifecycle owners use this before unloading a driver; + * a later human-authorized {@link resume} records the new activation edge. + * @param agent - owning live agent. + * @returns a fresh disarmed view, or `undefined` when no goal is current. + */ + disarm(agent: Agent): GoalView | undefined { + this.assertLive(agent) + const cache = this.cache(agent.session) + this.sync(agent.session, cache) + cache.activation = 'disarmed' + return this.view(cache) + } + /** * Create and arm a goal. A completed goal may be replaced; every other * current phase must be cleared or resumed instead. diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 57b2b5917b..ad2011fc62 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -231,6 +231,20 @@ describe('GoalService creation and replay', () => { expect(() => foldGoal(session.events)).not.toThrow() }) + it('lets a lifecycle owner disarm without writing a durable revision', async () => { + const { ctx, agent, session } = await harness() + const goal = ctx.goals.create(agent, { objective: 'survive driver reload' }) + const before = session.events.length + expect(ctx.goals.disarm(agent)).toMatchObject({ + id: goal.id, + revision: goal.revision, + phase: 'active', + activation: 'disarmed', + }) + expect(session.events).toHaveLength(before) + expect(ctx.goals.resume(agent, goal)).toMatchObject({ revision: 2, activation: 'armed' }) + }) + it('removes the service and its session-start listener with the providing fiber', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md new file mode 100644 index 0000000000..6b900e2a05 --- /dev/null +++ b/packages/goal/tool-goal/README.md @@ -0,0 +1,76 @@ +# @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 Agent Note](../../../.agents/notes/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, 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?, 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. + +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 and must describe it in `blocked_reason`. 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. + +##### 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, and report that concrete condition in blocked_reason; 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. + +#### 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 + +- **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 direct human rendering** — these tools mutate state only; the same-session driver and [`dsh-command-goal`](../command-goal/README.md) are independent consumers of the same domain. +- **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/package.json b/packages/goal/tool-goal/package.json new file mode 100644 index 0000000000..3fcc0c61a9 --- /dev/null +++ b/packages/goal/tool-goal/package.json @@ -0,0 +1,53 @@ +{ + "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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.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-invariants": "^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-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "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..41fe713dc6 --- /dev/null +++ b/packages/goal/tool-goal/src/authority.ts @@ -0,0 +1,109 @@ +/** 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 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 => + (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..075264f93e --- /dev/null +++ b/packages/goal/tool-goal/src/index.ts @@ -0,0 +1,276 @@ +/** + * 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 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' +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' +import type { GoalToolExecution } 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, objective, phase, completed ' + + '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 { + 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, 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. */ +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, + ...goal.blockedReason === undefined ? {} : { blockedReason: goal.blockedReason }, + }, + 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 } } +} + +/** Remember whether one autonomous terminal report should stop this turn. */ +function observeMutation( + terminalTurns: WeakMap, + execution: GoalToolExecution, + autonomousTerminal: boolean, +): void { + if (!autonomousTerminal) { + 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) + // 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 + terminalTurns.delete(agent) + return { action: 'stop' } + }) + 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 limit on automatic continuation rounds.', + }, + }, + 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 }, + }) + observeMutation(terminalTurns, execution, false) + 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 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.', + 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.' }, + blocked_reason: { + type: 'string', + description: 'Concrete blocking condition; required only with action blocked.', + }, + }, + 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) + 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([{ + type: 'text', + text: renderGoal(goal), + }]) + } + if (args.action === 'pause' || args.action === 'resume') { + requireDirectHuman(ctx, execution) + 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; blocked_reason is valid only with action blocked', + 'GOAL_TOOL_INVALID_UPDATE', + ) + } + const goal = args.action === 'pause' + ? ctx.goals.pause(execution.agent, ref) + : ctx.goals.resume(execution.agent, ref) + observeMutation(terminalTurns, execution, 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 === '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( + `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, { + 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.blocked_reason ?? args.objective ?? args.goal_id, + ), + })) +} diff --git a/packages/goal/tool-goal/src/invariant.ts b/packages/goal/tool-goal/src/invariant.ts new file mode 100644 index 0000000000..d3ea60f049 --- /dev/null +++ b/packages/goal/tool-goal/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-goal`. + * @module @deepseek-ai/dsh-tool-goal/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-goal' + +/** Cordis companion plugin name. */ +export const name = 'tool-goal-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this model-facing adapter owns no independent state or event protocol; + * accepted mutations are checked by the goal domain and authority behavior is package-tested. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ 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..2065bac23e --- /dev/null +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -0,0 +1,486 @@ +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_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' +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, supplied?: Session): StubAgent { + const session = supplied ?? 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?.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', 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' }) + 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 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) + 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') + 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 () => { + 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 }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined() + }) + + it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => { + const { ctx, root } = await harness() + 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', 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 () => { + 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 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 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) + 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', + 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', + blocked_reason: 'The required credential is still unavailable.', + }, root.agent) + 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 () => { + 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', + blocked_reason: 'The user asked to stop until a prerequisite is available.', + }, root.agent) + expect(resultGoal(blocked)).toMatchObject({ + phase: 'blocked', + blockedReason: { + code: 'model-reported', + message: 'The user asked to stop until a prerequisite is available.', + }, + roundsStarted: 0, + }) + }) +}) diff --git a/packages/goal/tool-goal/tsconfig.json b/packages/goal/tool-goal/tsconfig.json new file mode 100644 index 0000000000..f3026b0aff --- /dev/null +++ b/packages/goal/tool-goal/tsconfig.json @@ -0,0 +1,42 @@ +{ + "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" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/sdk/helper/README.md b/packages/sdk/helper/README.md index 45ad545458..89ca897d31 100644 --- a/packages/sdk/helper/README.md +++ b/packages/sdk/helper/README.md @@ -6,7 +6,7 @@ The package owns the builtin typed-spec catalog, provider/app behavior entities, All business and document validation completes before commit writes any affected file. Commit detects external edits made after the session opened, but deliberately provides no cross-file rollback after writing starts. -Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, timeout policy, and ask-user. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. +Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, timeout policy, and ask-user. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. The ACP app option contributes the human-command and user-interaction services before the bridge. `SdkProject.open()` requires only readable root `package.json` and `cordis.yml`. A Cordis config entry anchors feature installation; a package present only through a linked NPM dependency closure leaves the feature absent. Once an owned Cordis config entry exists, an incomplete resource shape is `inconsistent` and cannot be modified automatically. diff --git a/packages/sdk/helper/src/features/builtin/app.ts b/packages/sdk/helper/src/features/builtin/app.ts index 8e7793ab8f..835a9a8710 100644 --- a/packages/sdk/helper/src/features/builtin/app.ts +++ b/packages/sdk/helper/src/features/builtin/app.ts @@ -73,6 +73,10 @@ class AppOption extends FeatureOption { case 'acp': return new ProjectContribution([ ...appProjectResources(profile, this.id), + ...npmCordisConfigEntry(ID, { + id: 'commands', + name: '@deepseek-ai/dsh-commands', + }), ...npmCordisConfigEntry(ID, { id: 'user-interaction', name: '@deepseek-ai/dsh-user-interaction', diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index c04183fb6f..bc3e5ebe12 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -289,6 +289,7 @@ describe('SdkProject and ProjectEditSession', () => { edit.configureFeature(registry.get(featureId('app')), selection('app', ['acp'])) const acp = (await edit.commit()).project expect(acp.profile.runInterface).toBe('acp') + expect(acp.cordis.entry('commands')).toMatchObject({ name: '@deepseek-ai/dsh-commands' }) expect(acp.packageManifest().scripts).toMatchObject({ dev: 'dsh-sdk dev index.ts', start: 'dsh-sdk start index.js', diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 0735338d9a..435d352789 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -18,8 +18,9 @@ import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { existsSync } from 'node:fs' +import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' -import { join, delimiter } from 'node:path' +import { basename, dirname, join, delimiter } from 'node:path' import { ClientSideConnection, PROTOCOL_VERSION, @@ -41,12 +42,15 @@ export type { AgentUnderTest } from './launcher.ts' * the client observes the selected update (`agent_message_chunk` by default), * then cancels and awaits completion. A named `waitForToolCallUpdate` keeps the * step open for a terminal tool update that may follow the prompt response. + * `promptAndWaitForAgentMessage` arms an exact text-chunk waiter before sending + * the prompt, then keeps the application live until that later update arrives. */ export type InputStep = | { op: 'initialize'; terminalOutput?: boolean } | { op: 'newSession' } | { op: 'newSessionExpectError'; additionalDirectories?: string[] } | { op: 'prompt'; text: string } + | { op: 'promptAndWaitForAgentMessage'; text: string; waitForText: string } | { op: 'promptExpectError'; text: string } | { op: 'promptAndCancel' @@ -149,6 +153,13 @@ export interface RunOptions { configPath?: string } +/** Derive one stable, fixed-length spill root owned by this scenario. */ +function scenarioSpillRoot(fixtureFile: string): string { + const scenario = basename(dirname(fixtureFile)) + const key = createHash('sha256').update(scenario).digest('hex').slice(0, 9) + return `/tmp/dsh-acp-snap-${key}` +} + /** * Run a scenario end-to-end against a freshly-spawned subprocess. Owns the * child and its temp dirs; always tears them down. Returns the captured stdout @@ -163,7 +174,9 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-')) // Fixed path length: spill-policy budgets the preview against the REAL path // before stdout normalization, so tmpdir() length differences churn expected outputs. - const spillRoot = '/tmp/dsh-acp-snapshot-spill' + // Scenario ownership also matters: replay runs concurrently, and one teardown + // must never delete another scenario's in-flight full-output recovery file. + const spillRoot = scenarioSpillRoot(opts.fixtureFile) // Everything past the temp-dir creation is followed by failure-safe cleanup, // so a failure in workspace seeding, spawn, or any step never leaks resources. let launched: LaunchedAcpTestAgent | undefined @@ -331,6 +344,15 @@ async function runStep( await client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] }) return } + case 'promptAndWaitForAgentMessage': { + const sessionId = getSessionId() + if (sessionId === undefined) throw new Error('snapshot-harness: promptAndWaitForAgentMessage before newSession') + const updateDone = waitForUpdate(update => update.sessionUpdate === 'agent_message_chunk' + && update.content.type === 'text' && update.content.text === step.waitForText) + await client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] }) + await updateDone + return + } case 'promptExpectError': { const sessionId = getSessionId() if (sessionId === undefined) throw new Error('snapshot-harness: promptExpectError before newSession') diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index 0c21046eb2..6671959b29 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -20,7 +20,7 @@ const LOCAL_SPILL_PATH_RE = new RegExp( 'g', ) const SNAPSHOT_SPILL_PATH_RE = new RegExp( - String.raw`/tmp/dsh-acp-snapshot-spill/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)` + String.raw`/tmp/(?:dsh-acp-snap-[0-9a-f]{9}|dsh-acp-snapshot-spill)/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)` + String.raw`(?=\. Use read with offset/limit|[\s)]|$)`, 'g', ) diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index 43b5ee3fb3..277dc7565c 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -152,6 +152,7 @@ async function handlePrompt(id: number | string): Promise { mode: process.env.DSH_SNAPSHOT, override: process.env.DSH_SNAPSHOT_OVERRIDE ?? null, childFiles: process.env.DSH_SNAPSHOT_CHILD_FILES ?? null, + spillRoot: process.env.DSH_SNAPSHOT_SPILL_ROOT ?? null, })}`) } if (behavior.echoWorkspace === true) { diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 3d174b3c3e..4aa24e290a 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -60,6 +60,15 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }] +function environmentEcho(rawStdout: string): Record { + const frames = rawStdout.trim().split('\n') + .map(line => JSON.parse(line) as { params?: { update?: { content?: { text?: unknown } } } }) + const text = frames.map(frame => frame.params?.update?.content?.text) + .find(value => typeof value === 'string' && value.startsWith('env:')) + if (typeof text !== 'string') throw new Error('fake ACP agent did not echo its environment') + return JSON.parse(text.slice('env:'.length)) as Record +} + describe('runScenario', () => { it('surfaces an asynchronous child spawn failure through startup and close', async () => { const { dir } = await scenario({}) @@ -309,6 +318,19 @@ describe('runScenario', () => { expect(result.rawStdout).toContain(JSON.stringify(childFiles.join(delimiter)).slice(1, -1)) }) + it('gives concurrent scenarios distinct equal-length spill roots', { timeout: 20_000 }, async () => { + const [first, second] = await Promise.all([scenario({ echoEnv: true }), scenario({ echoEnv: true })]) + const results = await Promise.all([first, second].map(({ fixtureFile }) => runScenario( + { steps: [...boot, { op: 'prompt', text: 'env?' }] }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ))) + const roots = results.map(result => environmentEcho(result.rawStdout).spillRoot) + expect(roots.every(root => typeof root === 'string')).toBe(true) + expect(new Set(roots).size).toBe(2) + expect((roots[0] as string).length).toBe((roots[1] as string).length) + expect((roots[0] as string).length).toBe('/tmp/dsh-acp-snapshot-spill'.length) + }) + it('seeds the workspace dir into the temp cwd before the run', { timeout: 20_000 }, async () => { const { dir, fixtureFile } = await scenario({ echoWorkspace: true }) const workspaceDir = join(dir, 'workspace') @@ -334,6 +356,21 @@ describe('runScenario', () => { expect(result.rawStdout.indexOf('thinking about it')).toBeLessThan(result.rawStdout.indexOf('cancelled')) }) + it('promptAndWaitForAgentMessage keeps the app live through a matching later update', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ prompt: 'respond' }) + const result = await runScenario( + { + steps: [...boot, { + op: 'promptAndWaitForAgentMessage', + text: 'go', + waitForText: 'thinking about it', + }], + }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ) + expect(result.rawStdout).toContain('thinking about it') + }) + it('promptAndCancel can bracket cancellation with tool-call updates', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ prompt: 'hang-until-cancel', @@ -443,6 +480,7 @@ describe('runScenario', () => { it.each([ [{ op: 'prompt', text: 'x' }, /prompt before newSession/], + [{ op: 'promptAndWaitForAgentMessage', text: 'x', waitForText: 'later' }, /promptAndWaitForAgentMessage before newSession/], [{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/], [{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/], [{ op: 'cancel' }, /cancel before newSession/], diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index 2beaba5114..0ebfbe3255 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -139,6 +139,21 @@ describe('normalizeSessionLog', () => { expect(out).not.toContain('/tmp/dsh-acp-snapshot-spill') }) + it('scrubs scenario-owned snapshot spill paths', () => { + const ev = JSON.stringify({ + type: 'tool/result', seq: 2, time: 5, + data: { + content: [{ + type: 'text', + text: 'Full formatted result stored at: /tmp/dsh-acp-snap-012345678/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.', + }], + }, + }) + const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx) + expect(out).toContain('{{spillLocator:bash.txt}}') + expect(out).not.toContain('/tmp/dsh-acp-snap-012345678') + }) + it('scrubs the session id in the header', () => { const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx) expect(out).toContain('{{sessionId}}') diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 608c5d2056..e4866212b6 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -38,7 +38,7 @@ The current executable companions protect these relationships: | `dsh-llm`, `dsh-llm-retry`, `dsh-tools`, `dsh-system-prompt` | Stream grammar, durable retry position and bounds, tool-pipeline stages and frozen results, and authoritative prompt-assembly data. | | `dsh-compact`, `dsh-hook-protocol`, `dsh-sandbox-policy` | Durable compaction and hook pairing, compaction metadata, and sandbox-mode vocabulary. | | `dsh-fs`, `dsh-subagent`, `dsh-workflow` | Filesystem event identity, provider/child pairing, and workflow/agent lifecycle identity. | -| `dsh-goal` | Durable goal source/content agreement, revision and lifecycle transitions, timestamps, and sequential admitted rounds. | +| `dsh-goal`, `dsh-goal-session` | Durable goal source/content agreement, revision and lifecycle transitions, timestamps, sequential admitted rounds, and reconstructed continuation prompts. | | `dsh-permission`, `dsh-user-approval` | Active-preset references and approval asked/decided audit pairing. | | `dsh-tasks`, `dsh-tool-todo` | Task snapshot lifecycle/ownership fields and durable whole-list todo structure. | | `dsh-time-context` | Durable clock readings agree with the session's open turn and next pre-step position and elapsed baseline; rendered time parses and does not postdate its event. | diff --git a/packages/ui/README.md b/packages/ui/README.md index bd6b6c9406..aad4a9b628 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -5,6 +5,7 @@ Integrations that expose the agent to an external editor or client. These are ** | Package | Role | ctx key | |---|---|---| | `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | +| `commands/` | Human-command registry: shared discovery metadata, scoped shadowing, cancellation, and direct UI dispatch | `ctx.commands` | | `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` | | `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` | | `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` | @@ -13,7 +14,7 @@ Integrations that expose the agent to an external editor or client. These are ** | `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) | | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door; non-interactive tasks use the headless `cli-demo` app instead of a UI channel. +A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door; non-interactive tasks use the headless `cli-demo` app instead of a UI channel. [`commands`](commands/README.md) is the human-only discovery and dispatch plane shared by TUI and ACP; command input and output do not become model messages. `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers. diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 7a1a9fb00e..32b523cccd 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the terminal ` `apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface. -The plugin injects `agents`, `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms. +The plugin injects `agents`, [`commands`](../commands/README.md), `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the command registry backs slash discovery and direct dispatch; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms. ### Config @@ -26,10 +26,10 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: | ACP method | Harness seam | Notes | |---|---|---| | `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` | -| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected | -| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, and replays user, assistant, and tool events | -| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) | -| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) | +| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected | +| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, and tool events, and re-advertises commands | +| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; unsupported content and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC | +| `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another | | `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, and tool render intents | | `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice | | `session/request_permission` | `approval/request` listener | answers one-shot allow/reject requests for bridge-owned calls; foreign or call-less requests delegate and fail closed if unanswered — see "Permission prompts" | @@ -39,6 +39,12 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: One id-keyed record map plus exact agent-object checks route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session Agent Note](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md). +## Human commands + +After `session/new` and `session/load`, the bridge emits ACP's full `available_commands_update` snapshot for that exact agent. A new session's server-generated id is introduced by the RPC response before its snapshot enters the connection write queue. A global or scoped registry change refreshes every live session from its independently resolved view, so clients replace rather than merge cached catalogs. Names omit the slash; descriptions and optional unstructured-input hints map directly to ACP `AvailableCommand`. + +ACP v1 permits a command prompt to carry additional content blocks. The bridge applies its ordinary lossless flattening for supported `text` and `resource_link` blocks, then dispatches when the result begins with `/`. Known commands execute without a model request. Unknown or malformed slash input returns a direct error instead of falling back to the model; prefix whitespace when literal slash-leading text must reach the model. Expected handler errors, thrown failures, and successful text stream as UI-only `agent_message_chunk` output and end the request; cancellation returns `cancelled`. See the [command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) and the [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). + ## Session config options The bridge advertises a `model`-category select in `session/new` and `session/load` when the session has a complete target whose provider is registered. Values encode the complete provider/model pair, are grouped by provider when more than one group is available, and come from `ctx.llm.listProviders()` / `listModels()`. The configured or last-requested model is added when absent because catalogs are advisory and private adapters may accept unlisted ids. A selection changes only that ACP session. Agent-scoped prompt assembly snapshots the selected pair for one step, supplies matching `{{provider}}` / `{{model}}` variables, and the `agent/request` waterfall applies the same pair; a concurrent selection therefore takes effect on the next step instead of splitting prompt text from routing. The resulting request header is the durable record restored by `session/load`; a selection never used by a request remains in-memory only. @@ -108,6 +114,20 @@ Prompt tokens are data-dependent and remain in that session's history until comp Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. +### Human commands + +#### What the model sees + +Nothing from command discovery, slash input, or command output. A command handler may separately mutate a durable domain whose later state affects model requests. + +#### Token effect + +Direct dispatch adds no model tokens and no session message. The mutated domain owns any later prompt or history cost. + +#### KV Cache effect + +Command discovery, dispatch, and direct output never enter a model request and do not affect its cache. A mutated domain owns any later cache effect. + ### Human answers and permission decisions #### What the model sees @@ -170,3 +190,4 @@ Loading does not rewrite the stored log, but the next request is reconstructed u - **Prompt content is `text` + `resource_link` only** — image, audio, and embedded-resource blocks are rejected, as is a non-empty `mcpServers` list at `session/new`. - **Terminal cards render completed output** — live incremental streaming and command classification are named follow-ups of [the terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md). - **Permission answers are one-shot only** — the bridge offers `allow_once` / `reject_once`; durable `allow_always` grants and their storage/revocation policy remain deferred to the approval seam. +- **Command output is live-only** — discovery is refreshed after load, but direct command results are not persisted or replayed into a reconnected editor. diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 497973731e..60c7b19adf 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th ## At a glance -The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough**, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary). +The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary). ## 1. Agent methods (client → agent) @@ -23,8 +23,8 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i | `session/load` | S | ✅ | ✅ | ✅ | Maps to `agents.resume` + full event-log replay; validates persisted `cwd` before constructing the agent. | | `session/resume` | S | ❌ | ✅ | ✅ | Reconnect WITHOUT replay; gated by `sessionCapabilities.resume`. Not advertised. | | `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. | -| `session/prompt` | S | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. | -| `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. | +| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.send`. One request is in flight per session. | +| `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. | | `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement and modes are slated for removal in ACP v2 (see [§6](#6-session-modes--config-options--models)). | | `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. | | model selection | S | ✅ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. Values preserve the provider/model pair, catalogs come from `ctx.llm`, selection is per session, and `session/load` restores the last requested pair. Codex also supports the legacy `unstable_setSessionModel` ext method. | @@ -84,7 +84,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs | `tool_call` | S | ✅ | ✅ | ✅ | Tool-owned presentation (`presentCall`); see [§5](#5-tool-call-rendering). | | `tool_call_update` | S | ✅ | ✅ | ✅ | From appended `tool/result` via `presentResult`; replacement results rewrite model context and do not duplicate or overwrite execution presentation. | | `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). | -| `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. | +| `available_commands_update` | S | ✅ | ✅ | ✅ | Full effective snapshot after create/load and registry changes; names, descriptions, and unstructured-input hints come from `ctx.commands`. | | `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. | | `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox Agent Note § Per-session mode switching](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). | | `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). | @@ -142,11 +142,10 @@ Ranked by how commonly the reference adapters ship them and how much UX they unl 1. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`. 2. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries. -3. **Slash commands** (`available_commands_update`). -4. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`). -5. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path). -6. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`). -7. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access. +3. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`). +4. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path). +5. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`). +6. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access. ## Out of scope diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index 2d1b53e914..1eafb7fc70 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -34,6 +34,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-retry": "^0.0.1", @@ -53,6 +54,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 38eeb3f914..62455223a4 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -17,7 +17,9 @@ import { PROTOCOL_VERSION, RequestError, type Agent as AcpAgent, + type AnyMessage, type AuthenticateRequest, + type AvailableCommand, type CancelNotification, type ContentBlock as AcpContentBlock, type CreateElicitationRequest, @@ -46,6 +48,7 @@ import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from import { assertNever, CallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-commands' import { SessionId } from '@deepseek-ai/dsh-session' // Side-effect type import: resolves `ctx.get('permission')` to the service. import type {} from '@deepseek-ai/dsh-permission' @@ -77,13 +80,50 @@ import { export const name = 'acp' // Interface services back loading, presentation, interaction, and prompt assembly. -export const inject = ['agents', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt'] +export const inject = ['agents', 'commands', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt'] /** Preserve invalid-parameter detail in the SDK wire error message. */ function invalidParams(detail: string): RequestError { return RequestError.invalidParams(undefined, detail) } +/** Render arbitrary thrown values without trusting their string coercion. */ +function renderThrown(value: unknown): string { + try { + return String(value) + } catch { + return '' + } +} + +/** Return a server-created session id carried by an outbound success response. */ +function responseSessionId(message: AnyMessage): SessionId | undefined { + if (!('result' in message) || typeof message.result !== 'object' || message.result === null + || !('sessionId' in message.result) || typeof message.result.sessionId !== 'string') { + return undefined + } + return SessionId(message.result.sessionId) +} + +/** Observe messages only after the wrapped ACP transport has written them. */ +function observeOutbound(stream: Stream, onWritten: (message: AnyMessage) => void): Stream { + const writer = stream.writable.getWriter() + return { + readable: stream.readable, + writable: new WritableStream({ + async write(message) { + await writer.write(message) + onWritten(message) + }, + /* v8 ignore start -- the ACP SDK never closes or aborts its outbound stream; + preserve the wrapped Stream contract for other consumers nonetheless */ + close: () => writer.close(), + abort: (reason: unknown) => writer.abort(reason), + /* v8 ignore stop */ + }), + } +} + /** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */ function internalError(detail: string): RequestError { return RequestError.internalError(undefined, detail) @@ -260,6 +300,8 @@ interface SessionRecord { reject: (error: Error) => void turn: number | undefined } | undefined + /** Abort owner for a direct slash-command request, mutually exclusive with `inflight`. */ + commandAbort: AbortController | undefined /** Last idle switch per knob, anchored before the next prompt assembles. */ pendingSwitches: { preset?: string } } @@ -274,6 +316,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // ACP handlers execute outside this plugin's injection scope, so capture // injected services during apply(); lazy service reads in a handler fail. const agents = ctx.agents + const commands = ctx.commands const llm = ctx.llm const sessionPersistence = ctx.sessionPersistence const logger = ctx.logger @@ -380,6 +423,9 @@ export function apply(ctx: Context, config: AcpConfig): void { const sessions = new Map() // Reserve an id before resume so pipelined load/new requests cannot duplicate it. const loadingIds = new Set() + // A new-session response introduces its server-generated id to the client; + // keep its initial command snapshot pending until that response is written. + const pendingCommandSnapshots = new Map() // Async creation checks this after awaits to avoid publishing after teardown. let closed = false // Each new or loaded session snapshots the latest connection capability. @@ -468,6 +514,43 @@ export function apply(ctx: Context, config: AcpConfig): void { }) } + /** Project the effective registry view onto ACP discovery metadata. */ + const availableCommands = (agent: Agent): AvailableCommand[] => commands.list(agent).map(command => ({ + name: command.name, + description: command.description, + ...command.input === undefined ? {} : { input: { hint: command.input.hint } }, + })) + + /** Push the protocol's full-snapshot command catalog for one live session. */ + const notifyCommands = (rec: SessionRecord): void => { + notify({ + sessionId: rec.agent.session.id, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: availableCommands(rec.agent), + }, + }) + } + + /** Enqueue a new session's first command snapshot behind its written RPC response. */ + const announceInitialCommands = (message: AnyMessage): void => { + const sessionId = responseSessionId(message) + if (sessionId === undefined) return + const rec = pendingCommandSnapshots.get(sessionId) + if (rec === undefined) return + pendingCommandSnapshots.delete(sessionId) + notifyCommands(rec) + } + + // Registration and HMR removal can affect global or one scoped view; refresh + // every announced bridge-owned session and let the registry resolve each + // exact agent. A pending new-session snapshot will read the latest registry. + ctx.on('commands/change', () => { + for (const rec of sessions.values()) { + if (!pendingCommandSnapshots.has(rec.agent.session.id)) notifyCommands(rec) + } + }) + /** Settle the in-flight prompt with a stop reason, exactly once (no-op if none pending). */ const settlePrompt = (rec: SessionRecord, reason: StopReason): void => { const inflight = rec.inflight @@ -674,15 +757,18 @@ export function apply(ctx: Context, config: AcpConfig): void { await handle.dispose() throw internalError('connection closed during session/new') } - sessions.set(sessionId, { + const record: SessionRecord = { agent: handle.agent, dispose: () => handle.dispose(), presenter: makePresenter(handle.agent), terminalEnabled: terminalOutputCap, target, inflight: undefined, + commandAbort: undefined, pendingSwitches: {}, - }) + } + sessions.set(sessionId, record) + pendingCommandSnapshots.set(sessionId, record) const configOptions = configOptionsFor(handle.agent, directory) return { sessionId, ...configOptions.length > 0 ? { configOptions } : {} } }, @@ -763,6 +849,7 @@ export function apply(ctx: Context, config: AcpConfig): void { terminalEnabled, target, inflight: undefined, + commandAbort: undefined, pendingSwitches: {}, } sessions.set(sessionId, record) @@ -787,6 +874,7 @@ export function apply(ctx: Context, config: AcpConfig): void { for (const event of agent.session.events) { streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal) } + notifyCommands(record) const configOptions = configOptionsFor(agent, directory) return configOptions.length > 0 ? { configOptions } : {} } finally { @@ -797,7 +885,7 @@ export function apply(ctx: Context, config: AcpConfig): void { async prompt(params: PromptRequest): Promise { assertOpen() const rec = requireSession(SessionId(params.sessionId)) - if (rec.inflight !== undefined) { + if (rec.inflight !== undefined || rec.commandAbort !== undefined) { throw invalidParams('a prompt is already in flight for this session') } if (promptHasUnsupportedContent(params.prompt)) { @@ -810,6 +898,52 @@ export function apply(ctx: Context, config: AcpConfig): void { // waiting for a settle that never comes. throw invalidParams('empty prompt') } + // ACP command prompts may carry additional supported content blocks. + // The same lossless flattening used for model prompts supplies their + // unstructured command input; unsupported kinds were rejected above. + const commandLine = text.startsWith('/') ? text : undefined + if (commandLine !== undefined) { + const controller = new AbortController() + rec.commandAbort = controller + try { + const result = await commands.execute(rec.agent, commandLine, controller.signal) + if (result !== undefined && result.text !== undefined && result.text !== '') { + notify({ + sessionId: rec.agent.session.id, + update: { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: result.kind === 'error' ? `Error: ${result.text}` : result.text, + }, + }, + }) + } else if (result === undefined) { + notify({ + sessionId: rec.agent.session.id, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `Error: unknown command: ${commandLine}` }, + }, + }) + } + return { stopReason: 'end_turn' } + } catch (error: unknown) { + if (controller.signal.aborted) return { stopReason: 'cancelled' } + const rendered = renderThrown(error) + logger.warn(`acp: command failed: ${rendered}`) + notify({ + sessionId: rec.agent.session.id, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `Error: command failed: ${rendered}` }, + }, + }) + return { stopReason: 'end_turn' } + } finally { + rec.commandAbort = undefined + } + } // Install the in-flight slot BEFORE send() (send does not synchronously // flip status to running; the session/event listener records the turn // number and settle/rejects it). Capture the log length now as the @@ -837,8 +971,12 @@ export function apply(ctx: Context, config: AcpConfig): void { // settle it, because cancel() may drop the turn before any turn/end is // emitted, and removing this direct settle would move the RPC's // resolution onto a later observer path, changing its timing. - rec.agent.cancel('session/cancel') - settlePrompt(rec, 'cancelled') + if (rec.commandAbort !== undefined) { + rec.commandAbort.abort(new Error('session/cancel')) + } else { + rec.agent.cancel('session/cancel') + settlePrompt(rec, 'cancelled') + } return Promise.resolve() }, @@ -908,7 +1046,7 @@ export function apply(ctx: Context, config: AcpConfig): void { Writable.toWeb(process.stdout) as WritableStream, Readable.toWeb(process.stdin) as ReadableStream, ) - conn = new AgentSideConnection(makeAgent, stream) + conn = new AgentSideConnection(makeAgent, observeOutbound(stream, announceInitialCommands)) /** * Tear ALL live sessions down to quiescence (docs/defensive-patterns.md "dispose must reach @@ -946,12 +1084,14 @@ export function apply(ctx: Context, config: AcpConfig): void { // installed yet) must observe this after its await and refuse to install a // post-teardown record. Set even when there are no live sessions. closed = true + pendingCommandSnapshots.clear() const recs = [...sessions.values()] sessions.clear() if (recs.length === 0) return Promise.resolve() quiescing = (async () => { await Promise.all(recs.map(async (rec) => { settlePrompt(rec, 'cancelled') + rec.commandAbort?.abort(new Error('ACP connection closed')) // Per-agent dispose (the AgentHandle disposer): unregister this agent, // stop its loop (sets disposed + aborts the in-flight step), await // quiescence (the loop exit + final flush), and remove its session — so diff --git a/packages/ui/acp/tests/commands.spec.ts b/packages/ui/acp/tests/commands.spec.ts new file mode 100644 index 0000000000..71aae1ea64 --- /dev/null +++ b/packages/ui/acp/tests/commands.spec.ts @@ -0,0 +1,276 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { SessionId } from '@deepseek-ai/dsh-session' +import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' + +function commandUpdates(harness: BridgeHarness, sessionId: string) { + return harness.sessionUpdates.filter(update => update.sessionId === sessionId + && update.update.sessionUpdate === 'available_commands_update') +} + +function messageText(harness: BridgeHarness, sessionId: string): string { + return harness.sessionUpdates + .filter(update => update.sessionId === sessionId && update.update.sessionUpdate === 'agent_message_chunk') + .map(({ update }) => update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text' + ? update.content.text : '') + .join('') +} + +describe('ACP plugin commands', () => { + let storageDir: string + let harness: BridgeHarness | undefined + + beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-command-')) }) + afterEach(async () => { + if (harness !== undefined) await harness.dispose() + harness = undefined + await rm(storageDir, { recursive: true, force: true }) + }) + + it('publishes a full command snapshot after session creation and refreshes it dynamically', async () => { + harness = await makeBridgeHarness({ storageDir }) + harness.ctx.commands.register({ + name: 'inspect', + description: 'Inspect the session', + input: { hint: '' }, + handler: () => ({ kind: 'success' }), + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await vi.waitFor(() => { + expect(commandUpdates(harness!, sessionId).at(-1)?.update).toEqual({ + sessionUpdate: 'available_commands_update', + availableCommands: [{ + name: 'inspect', + description: 'Inspect the session', + input: { hint: '' }, + }], + }) + }) + + const dispose = harness.ctx.commands.register({ + name: 'alpha', + description: 'Alpha command', + handler: () => ({ kind: 'success' }), + }) + await vi.waitFor(() => { + expect(commandUpdates(harness!, sessionId).at(-1)?.update).toMatchObject({ + availableCommands: [{ name: 'alpha' }, { name: 'inspect' }], + }) + }) + dispose() + await vi.waitFor(() => { + expect(commandUpdates(harness!, sessionId).at(-1)?.update).toMatchObject({ + availableCommands: [{ name: 'inspect' }], + }) + }) + }) + + it('re-advertises commands after loading a persisted session', async () => { + const live = await makeBridgeHarness({ storageDir, script: [textResponse('persisted')] }) + await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist this session' }] }) + await live.dispose() + + harness = await makeBridgeHarness({ storageDir }) + harness.ctx.commands.register({ + name: 'loaded', description: 'Loaded command', handler: () => ({ kind: 'success' }), + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await harness.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] }) + + expect(commandUpdates(harness, sessionId).at(-1)?.update).toMatchObject({ + availableCommands: [{ name: 'loaded', description: 'Loaded command' }], + }) + }) + + it('coalesces registry changes before a new session command snapshot is announced', async () => { + harness = await makeBridgeHarness({ storageDir }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + harness.ctx.commands.register({ + name: 'raced', description: 'Registered after the response', handler: () => ({ kind: 'success' }), + }) + + await vi.waitFor(() => { + expect(commandUpdates(harness!, sessionId)).toHaveLength(1) + expect(commandUpdates(harness!, sessionId)[0]?.update).toMatchObject({ + availableCommands: [{ name: 'raced' }], + }) + }) + }) + + it('executes a known single-text command directly and never sends it to the model', async () => { + harness = await makeBridgeHarness({ storageDir }) + const seen = vi.fn(() => ({ kind: 'success' as const, text: 'DIRECT RESULT' })) + harness.ctx.commands.register({ name: 'direct', description: 'Run directly', handler: seen }) + harness.ctx.commands.register({ + name: 'silent', description: 'Return no text', handler: () => ({ kind: 'success' }), + }) + harness.ctx.commands.register({ + name: 'empty', description: 'Return empty text', handler: () => ({ kind: 'success', text: '' }), + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const response = await harness.client.prompt({ + sessionId, + prompt: [{ type: 'text', text: '/direct raw args ' }], + }) + + expect(response.stopReason).toBe('end_turn') + expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' raw args ' })) + expect(messageText(harness, sessionId)).toContain('DIRECT RESULT') + const updatesAfterText = harness.sessionUpdates.length + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/silent' }] }) + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/empty' }] }) + expect(harness.sessionUpdates).toHaveLength(updatesAfterText) + expect(harness.adapter.requests).toHaveLength(0) + expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0) + }) + + it('renders expected command errors and rejects unknown slash commands without model fallback', async () => { + harness = await makeBridgeHarness({ storageDir }) + harness.ctx.commands.register({ + name: 'denied', + description: 'Deny directly', + handler: () => ({ kind: 'error', text: 'not allowed now' }), + }) + harness.ctx.commands.register({ + name: 'throws', + description: 'Throw an ordinary error', + handler: () => { throw new Error('handler exploded') }, + }) + harness.ctx.commands.register({ + name: 'hostile', + description: 'Throw a hostile value', + handler: () => { + throw { toString(): string { throw new Error('coercion exploded') } } + }, + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/denied' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/missing input' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/throws' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/hostile' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + + expect(messageText(harness, sessionId)).toContain('Error: not allowed now') + expect(messageText(harness, sessionId)).toContain('Error: unknown command: /missing input') + expect(messageText(harness, sessionId)).toContain('Error: command failed: Error: handler exploded') + expect(messageText(harness, sessionId)).toContain('Error: command failed: ') + expect(harness.adapter.requests).toHaveLength(0) + }) + + it('flattens supported command prompt blocks without invoking the model', async () => { + harness = await makeBridgeHarness({ storageDir }) + const command = vi.fn(() => ({ kind: 'success' as const, text: 'combined' })) + harness.ctx.commands.register({ name: 'direct', description: 'Direct', handler: command }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await expect(harness.client.prompt({ + sessionId, + prompt: [ + { type: 'text', text: '/direct' }, + { type: 'text', text: ' extra' }, + { type: 'resource_link', name: 'input', uri: 'file:///workspace/input.txt' }, + ], + })).resolves.toEqual({ stopReason: 'end_turn' }) + expect(command).toHaveBeenCalledWith(expect.objectContaining({ + rawInput: ' extra\n[resource_link name="input" uri="file:///workspace/input.txt"]\n', + })) + expect(messageText(harness, sessionId)).toContain('combined') + expect(harness.adapter.requests).toHaveLength(0) + }) + + it('maps session cancellation to the in-flight command signal and isolates other sessions', async () => { + harness = await makeBridgeHarness({ storageDir }) + let started!: () => void + const ready = new Promise((resolve) => { started = resolve }) + harness.ctx.commands.register({ + name: 'wait', + description: 'Wait for cancellation', + handler: ({ signal }) => { + started() + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late abort result' }) }, { once: true }) + }) + }, + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const waiting = harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/wait' }] }) + await ready + await expect(harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/wait' }] })) + .rejects.toThrow(/already in flight/) + await harness.client.cancel({ sessionId: a.sessionId }) + + await expect(waiting).resolves.toEqual({ stopReason: 'cancelled' }) + await expect(harness.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: '/missing' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + expect(messageText(harness, a.sessionId)).not.toContain('late abort result') + }) + + it('aborts an in-flight command when the ACP bridge is disposed', async () => { + harness = await makeBridgeHarness({ storageDir }) + let started!: () => void + const ready = new Promise((resolve) => { started = resolve }) + let commandSignal: AbortSignal | undefined + harness.ctx.commands.register({ + name: 'wait-dispose', + description: 'Wait for bridge disposal', + handler: ({ signal }) => { + commandSignal = signal + started() + return new Promise(() => {}) + }, + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const waiting = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/wait-dispose' }] }) + await ready + await harness.acpFiber.dispose() + + expect(commandSignal?.aborted).toBe(true) + await expect(waiting).resolves.toEqual({ stopReason: 'cancelled' }) + }) + + it('resolves scoped command catalogs and execution independently per session', async () => { + harness = await makeBridgeHarness({ storageDir }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agentA = harness.ctx.agents.get(SessionId(a.sessionId)) + if (agentA === undefined) throw new Error('session A has no agent') + await agentA.ctx.inject(['commands'], (commandCtx) => { + commandCtx.commands.register({ + name: 'private', description: 'Only session A', + handler: () => ({ kind: 'success', text: 'A ONLY' }), + }) + }) + + await vi.waitFor(() => { + expect(commandUpdates(harness!, a.sessionId).at(-1)?.update).toMatchObject({ availableCommands: [{ name: 'private' }] }) + }) + expect(commandUpdates(harness, b.sessionId).at(-1)?.update).toMatchObject({ availableCommands: [] }) + await harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/private' }] }) + await harness.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: '/private' }] }) + expect(messageText(harness, a.sessionId)).toContain('A ONLY') + expect(messageText(harness, b.sessionId)).toContain('unknown command') + }) +}) diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts index 35dd54a634..fdbaf241e6 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -24,6 +24,9 @@ describe('acp bridge — demux & config edges', () => { harness = await makeBridgeHarness({ storageDir, script: [textResponse('foreign')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await vi.waitFor(() => { + expect(harness!.updates.some(update => update.sessionUpdate === 'available_commands_update')).toBe(true) + }) const before = harness.updates.length const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } }) diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index b8b47b80d5..b728ee5bfc 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -9,6 +9,7 @@ import { CallId, type GenerateOptions, type LlmModelInfo, type LlmProviderInfo, import { LlmAdapter } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import CommandService from '@deepseek-ai/dsh-commands' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -210,6 +211,7 @@ export async function makeBridgeHarness(options: { await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' }, }) + await ctx.plugin(CommandService) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir }) await ctx.plugin(UserInteractionService) diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json index eecc3c0de2..e0b3a7fe2c 100644 --- a/packages/ui/acp/tsconfig.json +++ b/packages/ui/acp/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../core/tools" }, + { + "path": "../commands" + }, { "path": "../user-interaction" }, diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md new file mode 100644 index 0000000000..6e0efa458b --- /dev/null +++ b/packages/ui/commands/README.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-commands + +Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plugin command registration Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) owns the boundary and protocol mapping. + +## Service contract + +`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. + +`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names. + +`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits. + +Handlers return `success` or `error` plus optional UI text. Results are rendered directly by the adapter and never enter model history. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it. + +## Composition + +The terminal and ACP app bundles mount this service with their consuming front door; the UI-less agent spine does not. Custom compositions that use `dsh-tui`, `dsh-acp`, or a command producer mount `@deepseek-ai/dsh-commands` explicitly. + +## Model Experience + +### Direct human commands + +#### What the model sees + +Nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. + +#### Token effect + +Command discovery, execution, and UI output add no model tokens. A command plugin may separately mutate a model-visible domain through that domain's durable APIs. + +#### KV Cache effect + +Registry metadata, command input, and direct output never enter a model request and do not affect its cache. A mutated domain owns any later cache effect. + +## Known Limitations and Deferred Work + +- **Only unstructured text input** — the descriptor intentionally matches ACP's current unstructured command input; forms, completion schemas, and typed arguments remain command-owned parsing concerns. +- **No persisted command output** — adapters display results live, but the generic registry does not add them to the session log or reconstruct them after reconnect. +- **Cooperative side-effect cancellation** — dispatch stops awaiting on abort; handlers must honor the signal to stop work that has already escaped into external systems. diff --git a/packages/ui/commands/package.json b/packages/ui/commands/package.json new file mode 100644 index 0000000000..0a777d22d8 --- /dev/null +++ b/packages/ui/commands/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-commands", + "description": "Plugin-owned human command registry for DeepSeek Harness UI surfaces", + "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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.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-invariants": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts new file mode 100644 index 0000000000..16e665e71f --- /dev/null +++ b/packages/ui/commands/src/index.ts @@ -0,0 +1,321 @@ +/** + * Plugin-owned human-command registry shared by interactive UI adapters. + * @module @deepseek-ai/dsh-commands + */ + +import { Context, Service } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { scopeOf } from '@deepseek-ai/dsh-scope' +import type { ScopeKey } from '@deepseek-ai/dsh-scope' + +export const name = 'commands' + +const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u + +/** Immutable command input metadata compatible with ACP unstructured input. */ +export interface CommandInputDescriptor { + /** Placeholder shown before the user supplies free-form input. */ + readonly hint: string +} + +/** Invocation passed to one registered command handler. */ +export interface CommandInvocation { + /** Exact agent whose human-facing surface received the command. */ + readonly agent: Agent + /** Exact text following the registered command name, including separator whitespace. */ + readonly rawInput: string + /** Cancellation signal owned by the dispatching UI request. */ + readonly signal: AbortSignal +} + +/** Expected command outcome rendered directly by the dispatching UI. */ +export type CommandResult = + | { readonly kind: 'success'; readonly text?: string } + | { readonly kind: 'error'; readonly text: string } + +/** Plugin-owned command registration. */ +export interface CommandDefinition { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor + /** Execute against the receiving agent without sending the command to the model. */ + readonly handler: (invocation: CommandInvocation) => CommandResult | Promise +} + +/** Handler-free immutable command view returned to UI adapters. */ +export interface CommandDescriptor { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor +} + +/** Syntactically valid slash command before registry resolution. */ +export interface ParsedCommand { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Exact text following the command name. */ + readonly rawInput: string +} + +interface RegisteredCommand { + readonly definition: CommandDefinition + readonly descriptor: CommandDescriptor +} + +declare module 'cordis' { + interface Context { + commands: CommandService + } + + interface Events { + /** + * A command was registered or unregistered. This is an unfiltered registry + * notification because a global or scoped change may affect any UI view. + * Observer failures are contained and cannot veto the registry mutation. + * @mode emit + */ + 'commands/change'(): void + } +} + +/** + * Parse an exact slash command without normalizing its trailing input. + * + * @param line - Complete candidate command line. + * @returns The parsed command, or `undefined` when the line is not a command. + */ +export function parseCommand(line: string): ParsedCommand | undefined { + const match = /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u.exec(line) + if (match === null) return undefined + const name = match[1] + /* v8 ignore next -- the first capture is required whenever the regular expression matches */ + if (name === undefined) return undefined + return Object.freeze({ name, rawInput: line.slice(match[0].length) }) +} + +/** Convert arbitrary abort reasons to one stable rejected Error. */ +function abortError(signal: AbortSignal): Error { + if (signal.reason instanceof Error) return signal.reason + return new Error(typeof signal.reason === 'string' ? signal.reason : 'command aborted') +} + +/** Render arbitrary thrown values without trusting their string coercion. */ +function renderThrown(value: unknown): string { + try { + return String(value) + } catch { + return '' + } +} + +/** Stop awaiting an uncooperative handler once its owning UI request aborts. */ +function withAbort(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(abortError(signal)) + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort) + reject(abortError(signal)) + } + signal.addEventListener('abort', onAbort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(error instanceof Error + ? error + : new Error(`command handler rejected with a non-Error value: ${renderThrown(error)}`, { cause: error })) + }, + ) + }) +} + +/** Reject invalid command metadata before it can reach a UI protocol. */ +function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { + if (!COMMAND_NAME.test(definition.name)) { + throw new TypeError(`command name "${definition.name}" must match ${String(COMMAND_NAME)}`) + } + if (typeof definition.description !== 'string') { + throw new TypeError(`command "${definition.name}" description must be a string`) + } + if (definition.description.trim().length === 0) { + throw new TypeError(`command "${definition.name}" description must not be empty`) + } + if (typeof definition.handler !== 'function') { + throw new TypeError(`command "${definition.name}" handler must be a function`) + } + const rawInput: unknown = definition.input + let input: CommandInputDescriptor | undefined + if (rawInput !== undefined) { + if (typeof rawInput !== 'object' || rawInput === null || !('hint' in rawInput) + || typeof rawInput.hint !== 'string') { + throw new TypeError(`command "${definition.name}" input hint must be a string`) + } + if (rawInput.hint.trim().length === 0) { + throw new TypeError(`command "${definition.name}" input hint must not be empty`) + } + input = Object.freeze({ hint: rawInput.hint }) + } + const normalized = Object.freeze({ + name: definition.name, + description: definition.description, + ...input === undefined ? {} : { input }, + handler: definition.handler, + }) + const descriptor = Object.freeze({ + name: normalized.name, + description: normalized.description, + ...normalized.input === undefined ? {} : { input: normalized.input }, + }) + return { definition: normalized, descriptor } +} + +/** Validate and detach an untrusted handler result at the registry boundary. */ +function normalizeResult(command: string, value: unknown): CommandResult { + if (typeof value !== 'object' || value === null || !('kind' in value)) { + throw new TypeError(`command "${command}" handler must return a CommandResult`) + } + const result = value as { kind?: unknown; text?: unknown } + if (result.kind === 'success') { + if (result.text !== undefined && typeof result.text !== 'string') { + throw new TypeError(`command "${command}" success text must be a string when supplied`) + } + return Object.freeze(result.text === undefined ? { kind: 'success' } : { kind: 'success', text: result.text }) + } + if (result.kind === 'error') { + if (typeof result.text !== 'string' || result.text.trim().length === 0) { + throw new TypeError(`command "${command}" error text must be a non-empty string`) + } + return Object.freeze({ kind: 'error', text: result.text }) + } + throw new TypeError(`command "${command}" returned unknown result kind "${String(result.kind)}"`) +} + +/** + * Human-command registry. Plain-context definitions are global; definitions + * registered through a command-injected child of an agent context shadow + * globals for that agent. + */ +export class CommandService extends Service { + private readonly global = new Map() + private readonly scoped = new Map>() + + constructor(ctx: Context) { + super(ctx, 'commands') + } + + /** + * Register a global or calling-agent-scoped command. + * @param definition - discovery metadata and direct UI handler. + * @returns the exact effect disposer that unregisters this definition. + */ + register(definition: CommandDefinition): () => void { + const scope = scopeOf(this.ctx) + const registered = normalizeDefinition(definition) + const dispose = this.ctx.effect(function* (this: CommandService) { + const layer = scope === undefined ? this.global : this.layerFor(scope) + if (layer.has(registered.definition.name)) { + throw new Error(scope === undefined + ? `command "${registered.definition.name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)` + : `command "${registered.definition.name}" is already registered in this scope`) + } + layer.set(registered.definition.name, registered) + yield () => { + layer.delete(registered.definition.name) + if (scope !== undefined && layer.size === 0) this.scoped.delete(scope) + this.notifyChange() + } + this.notifyChange() + }.bind(this), 'commands.register()') + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order + return dispose + } + + /** + * List the effective immutable command descriptors for one agent. + * @param agent - exact receiving agent and scoped-layer key. + * @returns name-sorted descriptors after scoped shadowing. + */ + list(agent: Agent): readonly CommandDescriptor[] { + return Object.freeze([...this.view(agent).values()] + .map(command => command.descriptor) + // Names are unique in the effective view, so equality is impossible. + .sort((left, right) => left.name < right.name ? -1 : 1)) + } + + /** + * Resolve one effective command definition. + * @param agent - exact receiving agent and scoped-layer key. + * @param name - command name without a slash. + * @returns the scoped shadow or global definition. + */ + find(agent: Agent, name: string): CommandDefinition | undefined { + return this.view(agent).get(name)?.definition + } + + /** + * Parse and execute a known command without sending it to the model. + * @param agent - exact receiving agent. + * @param line - complete slash-command line. + * @param signal - cancellation signal owned by the UI request. + * @returns a detached result, or `undefined` when syntax or name does not resolve. + */ + async execute( + agent: Agent, + line: string, + signal: AbortSignal, + ): Promise { + const parsed = parseCommand(line) + if (parsed === undefined) return undefined + const command = this.view(agent).get(parsed.name) + if (command === undefined) return undefined + if (signal.aborted) throw abortError(signal) + const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal }) + const output = command.definition.handler(invocation) + return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal)) + } + + /** Resolve global definitions followed by exact scoped shadows. */ + private view(agent: Agent): Map { + const visible = new Map(this.global) + for (const [name, command] of this.scoped.get(agent) ?? []) visible.set(name, command) + return visible + } + + /** Create the registration layer for one agent scope on demand. */ + private layerFor(scope: ScopeKey): Map { + let layer = this.scoped.get(scope) + if (layer === undefined) { + layer = new Map() + this.scoped.set(scope, layer) + } + return layer + } + + /** Notify every registry observer without making UI refresh load-bearing. */ + private notifyChange(): void { + // Cordis emit uses Array.map: one synchronous throw starves later listeners, + // and returned promises are discarded. Registry notifications are + // non-vetoing, so contain each callback independently. + for (const callback of this.ctx.events.dispatch('emit', ['commands/change'])) { + try { + const returned: unknown = callback() + void Promise.resolve(returned).catch((error: unknown) => { + this.ctx.logger.warn(`commands/change listener rejected: ${renderThrown(error)}`) + }) + } catch (error: unknown) { + this.ctx.logger.warn(`commands/change listener threw: ${renderThrown(error)}`) + } + } + } +} + +export default CommandService diff --git a/packages/ui/commands/src/invariant.ts b/packages/ui/commands/src/invariant.ts new file mode 100644 index 0000000000..87751d7cb4 --- /dev/null +++ b/packages/ui/commands/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-commands`. + * @module @deepseek-ai/dsh-commands/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-commands' + +/** Cordis companion plugin name. */ +export const name = 'commands-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: registry notifications intentionally hide mutation details and contain + * observers, so list/find self-comparisons would duplicate implementation rather than detect drift. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts new file mode 100644 index 0000000000..839ccb0297 --- /dev/null +++ b/packages/ui/commands/tests/commands.spec.ts @@ -0,0 +1,294 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { createScope } from '@deepseek-ai/dsh-scope' +import type { Scope } from '@deepseek-ai/dsh-scope' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' +import CommandService, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands' + +function command(name: string, text = `ran:${name}`): CommandDefinition { + return { + name, + description: `command ${name}`, + handler: () => ({ kind: 'success', text }), + } +} + +async function mount(): Promise { + const ctx = new Context() + await ctx.plugin(CommandService) + return ctx +} + +/** Mint a scope whose key is sufficient for registry lookup and invocation. */ +async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; agent: Agent }> { + const agent = { id: name as SessionId } as Agent + let scope!: Scope + await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['commands'] })) + return { scope, agent } +} + +describe('parseCommand()', () => { + it.each([ + ['/goal', { name: 'goal', rawInput: '' }], + ['/goal create the thing', { name: 'goal', rawInput: ' create the thing' }], + ['/goal\ncreate the thing', { name: 'goal', rawInput: '\ncreate the thing' }], + ['/goal_name-2\t x ', { name: 'goal_name-2', rawInput: '\t x ' }], + ] as const)('parses %j without normalizing trailing input', (line, expected) => { + expect(parseCommand(line)).toEqual(expected) + }) + + it.each(['goal', ' /goal', '/', '/Goal', '/goal/path', '/goal🔥'])('rejects non-command boundary %j', (line) => { + expect(parseCommand(line)).toBeUndefined() + }) +}) + +describe('CommandService', () => { + it('lists immutable global descriptors with input metadata', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const definition: CommandDefinition = { + name: 'inspect', + description: 'Inspect state', + input: { hint: '' }, + handler: () => ({ kind: 'success' }), + } + ctx.commands.register(definition) + + const listed = ctx.commands.list(agent) + expect(listed).toEqual([{ + name: 'inspect', + description: 'Inspect state', + input: { hint: '' }, + }]) + expect(Object.isFrozen(listed)).toBe(true) + expect(Object.isFrozen(listed[0])).toBe(true) + expect(Object.isFrozen(listed[0]?.input)).toBe(true) + expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect' }) + expect(ctx.commands.find(agent, 'missing')).toBeUndefined() + }) + + it('sorts distinct effective command names', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register(command('zeta')) + ctx.commands.register(command('alpha')) + ctx.commands.register(command('middle')) + expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['alpha', 'middle', 'zeta']) + }) + + it('uses agent-scoped shadows and removes them with their scope', async () => { + const ctx = await mount() + const { scope, agent } = await mintAgentScope(ctx, 'a') + const other = { id: 'other' as SessionId } as Agent + ctx.commands.register(command('shared', 'global')) + scope.ctx.commands.register(command('shared', 'scoped')) + + expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared']) + expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined() + expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared']) + expect(await ctx.commands.execute(agent, '/shared', new AbortController().signal)) + .toEqual({ kind: 'success', text: 'scoped' }) + + await scope.dispose() + expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global') + }) + + it('rejects duplicates within one layer while allowing a scoped shadow', async () => { + const ctx = await mount() + const { scope } = await mintAgentScope(ctx, 'a') + ctx.commands.register(command('same')) + expect(() => ctx.commands.register(command('same'))).toThrow(/agent\.ctx/) + scope.ctx.commands.register(command('same')) + expect(() => scope.ctx.commands.register(command('same'))).toThrow(/already registered in this scope/) + }) + + it('notifies on registration and disposal while containing broken observers', async () => { + const ctx = await mount() + const changed = vi.fn() + ctx.on('commands/change', changed) + const dispose = ctx.commands.register(command('live')) + dispose() + dispose() + expect(changed).toHaveBeenCalledTimes(2) + + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + ctx.on('commands/change', () => { throw new Error('observer threw') }) + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment + ctx.on('commands/change', () => Promise.reject(new Error('observer rejected'))) + const afterFailures = vi.fn() + ctx.on('commands/change', afterFailures) + const removeContained = ctx.commands.register(command('contained')) + const { agent } = await mintAgentScope(ctx, 'a') + expect(ctx.commands.find(agent, 'contained')).toBeDefined() + expect(afterFailures).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(warn).toHaveBeenCalledWith('commands/change listener threw: Error: observer threw') + expect(warn).toHaveBeenCalledWith('commands/change listener rejected: Error: observer rejected') + }) + removeContained() + expect(ctx.commands.find(agent, 'contained')).toBeUndefined() + expect(afterFailures).toHaveBeenCalledTimes(2) + }) + + it('rejects non-string descriptions and input hints with boundary diagnostics', async () => { + const ctx = await mount() + expect(() => ctx.commands.register({ + ...command('description-type'), + description: undefined, + } as unknown as CommandDefinition)).toThrow('command "description-type" description must be a string') + expect(() => ctx.commands.register({ + ...command('hint-type'), + input: { hint: 42 }, + } as unknown as CommandDefinition)).toThrow('command "hint-type" input hint must be a string') + expect(() => ctx.commands.register({ + ...command('input-type'), + input: null, + } as unknown as CommandDefinition)).toThrow('command "input-type" input hint must be a string') + }) + + it('passes exact invocation context and detaches valid handler results', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const seen = vi.fn(() => ({ kind: 'success' as const, text: 'ok' })) + ctx.commands.register({ name: 'run', description: 'Run it', handler: seen }) + const controller = new AbortController() + + const result = await ctx.commands.execute(agent, '/run untouched ', controller.signal) + + expect(result).toEqual({ kind: 'success', text: 'ok' }) + expect(Object.isFrozen(result)).toBe(true) + expect(seen).toHaveBeenCalledWith(expect.objectContaining({ + agent, + rawInput: ' untouched ', + signal: controller.signal, + })) + await expect(ctx.commands.execute(agent, 'run', controller.signal)).resolves.toBeUndefined() + await expect(ctx.commands.execute(agent, '/missing', controller.signal)).resolves.toBeUndefined() + }) + + it('stops awaiting an aborted handler and handles an already-aborted signal', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + let release!: (result: { kind: 'success'; text: string }) => void + ctx.commands.register({ + name: 'wait', + description: 'Wait', + handler: () => new Promise((resolve) => { release = resolve }), + }) + const running = new AbortController() + const promise = ctx.commands.execute(agent, '/wait', running.signal) + running.abort('operator cancelled command') + await expect(promise).rejects.toThrow('operator cancelled command') + release({ kind: 'success', text: 'late' }) + + const already = new AbortController() + already.abort(new Error('already gone')) + await expect(ctx.commands.execute(agent, '/wait', already.signal)).rejects.toThrow('already gone') + + const defaultReason = new AbortController() + defaultReason.abort({ source: 'test' }) + await expect(ctx.commands.execute(agent, '/wait', defaultReason.signal)).rejects.toThrow('command aborted') + }) + + it('propagates an asynchronously rejected handler', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register({ + name: 'reject', + description: 'Reject', + handler: () => Promise.reject(new Error('handler rejected')), + }) + await expect(ctx.commands.execute(agent, '/reject', new AbortController().signal)) + .rejects.toThrow('handler rejected') + + ctx.commands.register({ + name: 'reject-value', + description: 'Reject a non-Error value', + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise untyped plugin normalization + handler: () => Promise.reject('not an Error'), + }) + await expect(ctx.commands.execute(agent, '/reject-value', new AbortController().signal)) + .rejects.toThrow('command handler rejected with a non-Error value: not an Error') + + const hostile = { toString(): string { throw new Error('cannot render') } } + ctx.commands.register({ + name: 'reject-hostile', + description: 'Reject an unrenderable value', + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise hostile plugin normalization + handler: () => Promise.reject(hostile), + }) + await expect(ctx.commands.execute(agent, '/reject-hostile', new AbortController().signal)) + .rejects.toMatchObject({ + message: 'command handler rejected with a non-Error value: ', + cause: hostile, + }) + }) + + it('observes an abort triggered synchronously inside the handler', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const controller = new AbortController() + ctx.commands.register({ + name: 'self-abort', + description: 'Abort before returning', + handler: () => { + controller.abort('aborted in handler') + return { kind: 'success' } + }, + }) + await expect(ctx.commands.execute(agent, '/self-abort', controller.signal)) + .rejects.toThrow('aborted in handler') + }) + + it('returns a detached expected-error result', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register({ + name: 'denied', + description: 'Denied', + handler: () => ({ kind: 'error', text: 'not now' }), + }) + const result = await ctx.commands.execute(agent, '/denied', new AbortController().signal) + expect(result).toEqual({ kind: 'error', text: 'not now' }) + expect(Object.isFrozen(result)).toBe(true) + + ctx.commands.register({ + name: 'silent', + description: 'No output', + handler: () => ({ kind: 'success' }), + }) + const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal) + expect(silent).toEqual({ kind: 'success' }) + expect(Object.isFrozen(silent)).toBe(true) + }) + + it.each([ + [{ ...command('Bad') }, /command name/], + [{ ...command('empty-description'), description: ' ' }, /description/], + [{ ...command('empty-hint'), input: { hint: '' } }, /input hint/], + [{ ...command('bad-handler'), handler: undefined }, /handler/], + ] as const)('rejects invalid definition %#', async (definition, expected) => { + const ctx = await mount() + expect(() => ctx.commands.register(definition as unknown as CommandDefinition)).toThrow(expected) + }) + + it.each([ + [undefined, /CommandResult/], + [null, /CommandResult/], + [{}, /CommandResult/], + [{ kind: 'success', text: 1 }, /success text/], + [{ kind: 'error', text: '' }, /error text/], + [{ kind: 'error', text: 1 }, /error text/], + [{ kind: 'future', text: 'x' }, /unknown result kind/], + ] as const)('rejects malformed handler result %j', async (output, expected) => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register({ + name: 'broken', + description: 'Broken', + handler: () => output as never, + }) + await expect(ctx.commands.execute(agent, '/broken', new AbortController().signal)).rejects.toThrow(expected) + }) +}) diff --git a/packages/ui/commands/tsconfig.json b/packages/ui/commands/tsconfig.json new file mode 100644 index 0000000000..8f0448250f --- /dev/null +++ b/packages/ui/commands/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/scope" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index dd6553d09f..550652c22d 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -6,13 +6,13 @@ The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feat Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification. -This package owns interactive terminal presentation and input only. It injects `agents`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. +This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Surface replacement events rebuild the transcript so compacted history does not reappear. Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. -While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide the same actions without key chords. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. ## Config @@ -39,7 +39,7 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t maxToolOutputLines: 12 ``` -Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. +Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. ## Color @@ -51,7 +51,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic #### What the model sees -Each non-empty editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only. +Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only; command results remain terminal notices. #### Token effect diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index 6405fbe522..1edc20ca4d 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-retry": "^0.0.1", @@ -45,6 +46,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index db696dde2a..cbb3593209 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -35,6 +35,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-loop' +import type {} from '@deepseek-ai/dsh-commands' import { errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' @@ -55,7 +56,7 @@ import { } from '@deepseek-ai/dsh-user-interaction' export const name = 'ui-tui' -export const inject = ['agents', 'userInteraction', 'tools'] +export const inject = ['agents', 'commands', 'userInteraction', 'tools'] /** Presentation settings for the pi-tui terminal mode. */ export interface TuiConfig { @@ -855,6 +856,7 @@ export function createTuiChat( const allToolCards = new Set() const liveErrors = new Set() const questionQueue: PendingQuestion[] = [] + const commandControllers = new Set() let activeQuestion: PendingQuestion | undefined const welcome = config.welcome ?? 'ready.' @@ -1129,6 +1131,8 @@ export function createTuiChat( shuttingDown ??= (async () => { disposed = true clearStatus() + for (const controller of commandControllers) controller.abort(new Error('TUI disposed')) + commandControllers.clear() if (activeQuestion !== undefined) { const pending = activeQuestion activeQuestion = undefined @@ -1153,16 +1157,6 @@ export function createTuiChat( void shutdown(true) } - editor.setAutocompleteProvider(new CombinedAutocompleteProvider([ - { name: 'help', description: 'Show keyboard shortcuts and commands' }, - { name: 'clear', description: 'Clear the transcript view (session history is unchanged)' }, - { name: 'cancel', description: 'Cancel the active turn' }, - { name: 'reasoning', description: 'Toggle reasoning blocks' }, - { name: 'tools', description: 'Expand or collapse all tool cards' }, - { name: 'redraw', description: 'Invalidate components and redraw the terminal' }, - { name: 'exit', description: 'Exit after the active turn reaches idle' }, - ], agent.session.header.cwd ?? process.cwd())) - const toggleTools = (): void => { toolsExpanded = !toolsExpanded for (const card of allToolCards) card.setExpanded(toolsExpanded) @@ -1182,52 +1176,107 @@ export function createTuiChat( } const showHelp = (): void => { + const commandLines = ctx.commands.list(agent).map((command) => { + const input = command.input === undefined ? '' : ` ${command.input.hint}` + return `/${command.name}${input} — ${command.description}` + }) chat.addChild(new Spacer(1)) chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0)) chat.addChild(new Text([ 'Enter send • Shift/Alt+Enter newline • Up/Down prompt history', 'Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning', 'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit', - '/help /clear /cancel /reasoning /tools /redraw /exit', + '', + ...commandLines, ].map(line => palette.muted(line)).join('\n'), 1, 0)) requestRender() } + const refreshCommandAutocomplete = (): void => { + editor.setAutocompleteProvider(new CombinedAutocompleteProvider( + ctx.commands.list(agent).map(command => ({ + name: command.name, + description: command.description, + })), + agent.session.header.cwd ?? process.cwd(), + )) + } + const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete) + refreshCommandAutocomplete() + + // The agent scope is minted by agent-loop and intentionally inherits only + // that core plugin's dependencies. A child command producer declares its own + // UI-service dependency while retaining the parent agent scope and lifetime. + const commandFiber = agent.ctx.inject(['commands'], (commandCtx) => { + commandCtx.commands.register({ + name: 'help', + description: 'Show keyboard shortcuts and commands', + handler: () => { showHelp(); return { kind: 'success' } }, + }) + commandCtx.commands.register({ + name: 'clear', + description: 'Clear the transcript view (session history is unchanged)', + handler: () => { chat.clear(); requestRender(); return { kind: 'success' } }, + }) + commandCtx.commands.register({ + name: 'cancel', + description: 'Cancel the active turn', + handler: () => { + if (agent.status !== 'running') return { kind: 'error', text: 'The agent is already idle.' } + agent.cancel('cancelled from terminal') + return { kind: 'success', text: 'Cancellation requested.' } + }, + }) + commandCtx.commands.register({ + name: 'reasoning', + description: 'Toggle reasoning blocks', + handler: () => { toggleReasoning(); return { kind: 'success' } }, + }) + commandCtx.commands.register({ + name: 'tools', + description: 'Expand or collapse all tool cards', + handler: () => { toggleTools(); return { kind: 'success' } }, + }) + commandCtx.commands.register({ + name: 'redraw', + description: 'Invalidate components and redraw the terminal', + handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } }, + }) + commandCtx.commands.register({ + name: 'exit', + description: 'Exit after the active turn reaches idle', + handler: () => { requestExit(); return { kind: 'success' } }, + }) + }) + + const runCommand = (text: string): void => { + const controller = new AbortController() + commandControllers.add(controller) + void ctx.commands.execute(agent, text, controller.signal).then( + (result) => { + if (disposed) return + if (result === undefined) { + appendNotice(`Unknown command: ${text}`, 'warning') + } else if (result.text !== undefined && result.text !== '') { + appendNotice(result.text, result.kind === 'error' ? 'error' : 'info') + } + }, + (error: unknown) => { + if (!disposed) { + appendNotice(`Command failed: ${errorChain(error)}`, 'error') + } + }, + ).finally(() => { commandControllers.delete(controller) }) + } + editor.onSubmit = (value: string) => { const text = value.trim() if (text === '') return editor.addToHistory(text) editor.setText('') - switch (text) { - case '/help': - showHelp() - return - case '/clear': - chat.clear() - requestRender() - return - case '/cancel': - if (agent.status === 'running') agent.cancel('cancelled from terminal') - else appendNotice('The agent is already idle.') - return - case '/reasoning': - toggleReasoning() - return - case '/tools': - toggleTools() - return - case '/redraw': - ui.invalidate() - ui.requestRender(true) - return - case '/exit': - requestExit() - return - default: - if (text.startsWith('/')) { - appendNotice(`Unknown command: ${text}`, 'warning') - return - } + if (value.startsWith('/')) { + runCommand(value) + return } if (agent.status === 'disposed') { appendNotice(`Agent "${agent.id}" is disposed.`, 'error') @@ -1304,6 +1353,7 @@ export function createTuiChat( const detachListeners = (): void => { removeInputListener() + disposeCommandChanges() disposeSessionEvents() disposeStatus() disposeError() @@ -1317,6 +1367,12 @@ export function createTuiChat( } catch (error: unknown) { disposed = true detachListeners() + void commandFiber.dispose().catch( + /* v8 ignore next 2 -- command registration cleanup is non-throwing; this guards a future disposer regression */ + (cleanupError: unknown) => { + ctx.logger.warn(`ui-tui: command cleanup after startup failure failed: ${errorChain(cleanupError)}`) + }, + ) clearStatus() disposeUserInteraction() ui.stop() @@ -1327,6 +1383,7 @@ export function createTuiChat( async dispose(): Promise { detachListeners() await shutdown(false) + await commandFiber.dispose() }, } } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index fcf8105350..aa3f593396 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -1,6 +1,7 @@ import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' +import CommandService from '@deepseek-ai/dsh-commands' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' @@ -47,6 +48,7 @@ export async function createTuiTestHarness { const unwrapped = loader.unwrapExports(tui) as Record expect(unwrapped).toBe(tui) expect(unwrapped.name).toBe('ui-tui') - expect(unwrapped.inject).toEqual(['agents', 'userInteraction', 'tools']) + expect(unwrapped.inject).toEqual(['agents', 'commands', 'userInteraction', 'tools']) expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') }) diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index 05809dea0c..5006083358 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=1 progress=inactive title "DSH snapshot" -cursor visible column=0 viewportRow=22 bufferRow=22 +cursor visible column=0 viewportRow=29 bufferRow=29 buffer 0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" style 0-91 fg=bright-blue @@ -29,24 +29,37 @@ buffer style 1-75 fg=bright-black 9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " style 1-73 fg=bright-black -10| " /help /clear /cancel /reasoning /tools /redraw /exit " - style 1-52 fg=bright-black -11| -12| " Unknown command: /unknown-advanced-command " - style 1-42 fg=yellow -13| -14| " provider stream failed after partial output " +10| " " +11| " /cancel — Cancel the active turn " + style 1-32 fg=bright-black +12| " /clear — Clear the transcript view (session history is unchanged) " + style 1-65 fg=bright-black +13| " /exit — Exit after the active turn reaches idle " + style 1-47 fg=bright-black +14| " /help — Show keyboard shortcuts and commands " + style 1-44 fg=bright-black +15| " /reasoning — Toggle reasoning blocks " + style 1-36 fg=bright-black +16| " /redraw — Invalidate components and redraw the terminal " + style 1-55 fg=bright-black +17| " /tools — Expand or collapse all tool cards " + style 1-42 fg=bright-black +18| +19| " provider stream failed after partial output " style 1-43 fg=red -15| -16| " The previous process ended during this turn. " +20| +21| " The previous process ended during this turn. " style 1-44 fg=yellow -17| "────────────────────────────────────────────────────────────────────────────────────────────" +22| +23| " Unknown command: /unknown-advanced-command " + style 1-42 fg=yellow +24| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -18| " " +25| " " style 1-1 inverse -19| "────────────────────────────────────────────────────────────────────────────────────────────" +26| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" style 0-24 dim style 59-91 dim -21-31| +28-31| diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index fccfca604b..4a99d7e9e7 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=18 bufferRow=18 +cursor hidden column=1 viewportRow=25 bufferRow=25 buffer 0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" style 0-91 fg=bright-blue @@ -29,24 +29,37 @@ buffer style 1-75 fg=bright-black 9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " style 1-73 fg=bright-black -10| " /help /clear /cancel /reasoning /tools /redraw /exit " - style 1-52 fg=bright-black -11| -12| " Unknown command: /unknown-advanced-command " - style 1-42 fg=yellow -13| -14| " provider stream failed after partial output " +10| " " +11| " /cancel — Cancel the active turn " + style 1-32 fg=bright-black +12| " /clear — Clear the transcript view (session history is unchanged) " + style 1-65 fg=bright-black +13| " /exit — Exit after the active turn reaches idle " + style 1-47 fg=bright-black +14| " /help — Show keyboard shortcuts and commands " + style 1-44 fg=bright-black +15| " /reasoning — Toggle reasoning blocks " + style 1-36 fg=bright-black +16| " /redraw — Invalidate components and redraw the terminal " + style 1-55 fg=bright-black +17| " /tools — Expand or collapse all tool cards " + style 1-42 fg=bright-black +18| +19| " provider stream failed after partial output " style 1-43 fg=red -15| -16| " The previous process ended during this turn. " +20| +21| " The previous process ended during this turn. " style 1-44 fg=yellow -17| "────────────────────────────────────────────────────────────────────────────────────────────" +22| +23| " Unknown command: /unknown-advanced-command " + style 1-42 fg=yellow +24| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -18| " " +25| " " style 1-1 inverse -19| "────────────────────────────────────────────────────────────────────────────────────────────" +26| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" style 0-24 dim style 59-91 dim -21-31| +28-31| diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index e7b755e612..026d889671 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -502,6 +503,108 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(disposedAgent) }) + it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => { + const result = await setup() + const handler = vi.fn(({ rawInput }: CommandInvocation) => ({ + kind: 'success' as const, + text: `PLUGIN:${rawInput}`, + })) + result.ctx.commands.register({ + name: 'plugin-check', + description: 'Run a plugin command', + input: { hint: '' }, + handler, + }) + result.ctx.commands.register({ + name: 'plugin-fail', + description: 'Fail a plugin command', + handler: () => { throw new Error('plugin command exploded') }, + }) + + result.terminal.send('/plugin-check value ') + result.terminal.send('\r') + await tick() + + expect(handler).toHaveBeenCalledTimes(1) + const invocation = handler.mock.calls[0]?.[0] + expect(invocation?.agent).toBe(result.agent) + // pi-tui's Editor owns terminal-line normalization and removes trailing + // spaces before onSubmit; the registry preserves the adapter-delivered line. + expect(invocation?.rawInput).toBe(' value') + expect(result.terminal.output).toContain('PLUGIN: value') + result.terminal.send('/plugin-fail') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Command failed: plugin command exploded') + result.terminal.send('/help') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('/plugin-check — Run a plugin command') + expect(result.ctx.commands.list(result.agent).map(command => command.name)).toContain('help') + + await result.controller.dispose() + expect(result.ctx.commands.list(result.agent).map(command => command.name)).toEqual([ + 'plugin-check', + 'plugin-fail', + ]) + await result.ctx.fiber.dispose() + }) + + it('aborts an in-flight plugin command during TUI disposal', async () => { + const result = await setup() + let started!: () => void + const ready = new Promise((resolve) => { started = resolve }) + let commandSignal: AbortSignal | undefined + result.ctx.commands.register({ + name: 'wait-plugin', + description: 'Wait until disposal', + handler: ({ signal }) => { + commandSignal = signal + started() + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late result' }) }, { once: true }) + }) + }, + }) + + result.terminal.send('/wait-plugin') + result.terminal.send('\r') + await ready + await result.controller.dispose() + + expect(commandSignal?.aborted).toBe(true) + expect(result.terminal.output).not.toContain('late result') + await result.ctx.fiber.dispose() + }) + + it('suppresses a successful plugin result that settles as TUI disposal starts', async () => { + const result = await setup() + let started!: () => void + const ready = new Promise((resolve) => { started = resolve }) + let resolveCommand!: (result: { kind: 'success'; text: string }) => void + result.ctx.commands.register({ + name: 'late-success', + description: 'Resolve while the TUI closes', + handler: () => new Promise((resolve) => { + resolveCommand = resolve + started() + }), + }) + + result.terminal.send('/late-success') + result.terminal.send('\r') + await ready + resolveCommand({ kind: 'success', text: 'must not render after disposal' }) + // Let the command boundary accept the result before disposal, but leave the + // TUI continuation queued so the success-side disposal guard owns the race. + await Promise.resolve() + await result.controller.dispose() + await tick() + + expect(result.terminal.output).not.toContain('must not render after disposal') + await result.ctx.fiber.dispose() + }) + it('cancels before /exit while running and handles agent errors/disposal', async () => { const result = await setup({ status: 'running' }) result.terminal.send('/exit') @@ -897,6 +1000,7 @@ describe('terminal mounting', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('main')) @@ -915,6 +1019,7 @@ describe('terminal mounting', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() @@ -943,6 +1048,7 @@ describe('terminal mounting', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() @@ -970,6 +1076,7 @@ describe('terminal mounting', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() @@ -990,6 +1097,7 @@ describe('terminal mounting', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('failed-start-session')) @@ -1004,6 +1112,8 @@ describe('terminal mounting', () => { expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() })) .toThrow('terminal startup failed') + await tick() + expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([]) expect(terminal.stopped).toBe(1) expect(terminal.progress).toEqual([false, true, false]) await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] })) @@ -1021,6 +1131,7 @@ describe('terminal mounting', () => { it('throws when createTuiChat is called without the configured agent', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const runtime: TuiRuntime = { terminal: new FakeTerminal(), exit: vi.fn() } diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index 58863e92ab..2cb02f897d 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../core/tools" }, + { + "path": "../commands" + }, { "path": "../user-interaction" }, diff --git a/packages/workflow/README.md b/packages/workflow/README.md index c89e05c6a7..5c0d51724b 100644 --- a/packages/workflow/README.md +++ b/packages/workflow/README.md @@ -7,7 +7,8 @@ The workflow seam: a model-written JavaScript orchestration script that fans out | `workflow/` | Abstract workflow seam: service base class + run vocabulary + `workflow/*` events | `ctx.workflows` | | `workflow-workerthread/` | `node:worker_threads` engine: one worker per run; the script's vm context lives inside the worker, `agent()` bridges to `ctx.subagents` over the message port | (provides `ctx.workflows`) | | `tool-workflow/` | Model-facing `workflow` tool over `ctx.workflows` | (registers on `ctx.tools`) | +| `tool-ralph/` | Fixed fresh-agent Ralph policy over `ctx.workflows` and a fresh structured-output subagent provider | (registers on `ctx.tools`) | The interface lives at `workflow/workflow/`. The engine's `agent()` hook rides the [subagent seam](../subagent/README.md) (any registered provider; the shipped examples use `spawn`), and `agent({ schema })` rides the structured-output support the in-process backends implement. The worker thread isolates the SCRIPT — the host never blocks on it, and a cancelled run's post-grace termination is real — but it is NOT a security boundary; an isolated-vm/separate-process engine (actual sandboxing) swaps in behind the same interface if that ever matters. -The proposal, decisions, and deferred work: [.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md). +The general script engine's decisions and deferred work live in the [dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md). The separate [Ralph consumer](../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md) fixes the script and fresh-provider policy rather than adding another engine or an agent-loop mode. diff --git a/packages/workflow/tool-ralph/README.md b/packages/workflow/tool-ralph/README.md new file mode 100644 index 0000000000..230e2db643 --- /dev/null +++ b/packages/workflow/tool-ralph/README.md @@ -0,0 +1,91 @@ +# @deepseek-ai/dsh-tool-ralph + +The model-facing `ralph` tool runs a fixed foreground workflow that gives one immutable objective to a sequence of fresh child agents. It demonstrates a specialized orchestration policy as an ordinary plugin over [`ctx.workflows`](../workflow/README.md) and [`ctx.subagents`](../../subagent/subagent/README.md): no Ralph mode or fresh-agent loop is added to `agent-loop`, and the same-session [goal domain](../../goal/goal/README.md) remains independent. The [Ralph Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md) owns the policy and deferred work. + +## Contract + +`ralph({ objective, maxRounds? })` waits for the entire run. The deployment config's `maxRounds` is both the default and a ceiling on a call override. Every Ralph round starts one child through `subagentProvider`; that provider must exist, support structured output, and report `inheritsParentContext: false`. The configured provider is carried as `WorkflowStartRequest.subagentProvider`, so the fixed script cannot inspect or change routing and the ordinary model-written `workflow` tool gains no provider selector. The resolved round cap is also carried as `WorkflowStartRequest.maxTotalAgents`, coordinating the fixed loop with the engine's total-child backstop; the engine rejects a Ralph cap above its deployment ceiling before publishing a run. + +Each child receives only the immutable objective, its current Ralph round and cap, a shared-workspace-as-authority instruction, and the previous structured handoff. The workspace is long-term memory; parent conversation and prior child sessions are not seeded. Reports have `status: continue | complete | blocked`, a non-empty summary, evidence, next steps, and blocker text. Status-specific semantics and the serialized `maxHandoffChars` ceiling are validated inside the fixed workflow and again at the consumer boundary. Invalid, missing, or oversized reports fail the workflow instead of being truncated or mistaken for cap exhaustion. + +The successful terminal tool result is `complete`, `blocked`, or `budget-limited`, with the last bounded report and number of rounds started. Completion and blocker labels explicitly say that a worker reported the outcome; they are not independent certification. `maxResultChars` bounds the complete successful text including its envelope and truncation marker, without altering the validated report used as a cross-round handoff. + +An ordinary child failure produces an error naming the failed round and retaining the last successful handoff when one exists. Ralph does not retry that round. Fatal provider-start, transport, worker, or workflow failures remain workflow errors and may settle before the fixed script can return a handoff. Cancellation is also an error; partial output is never success. + +## Lifecycle and cancellation + +The caller's agent is the parent of every fresh child, preserving cwd and lineage without copying its conversation. `exec.signal` enters the workflow engine and is also bridged to `run.cancel()` for implementation independence. The tool awaits `run.result` and calls `run.dispose()` in `finally`, so a cancelled parent step waits for the engine's bounded termination and child quiescence before returning. + +## Render intent + +The pending call is a `generic` card titled `ralph`; the immutable objective is its `rawInput`. The result keeps the generic card. Both presentation functions depend only on tool arguments and the settled tool envelope. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `subagentProvider` | `spawn` | Fresh structured-output provider used for every round. | +| `maxRounds` | `256` | Default and deployment ceiling for one Ralph run. | +| `maxHandoffChars` | `16384` | Maximum serialized characters in one round report. | +| `maxResultChars` | `16384` | Maximum characters in the complete successful parent result. | + +All config values are normalized and validated when the plugin applies, including direct application outside Loader schema normalization. Provider capabilities are resolved immediately before each call because provider registration can change under plugin lifecycle and HMR. + +## Model Experience + +### System prompt + +#### What the model sees + +Every parent request in this plugin's registration scope receives the fixed routing guidance below. + +##### Ralph guidance + +```markdown +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +``` + +#### Token effect + +Small fixed guidance cost per request while the plugin is active. + +#### KV Cache effect + +Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section. + +### Tool schema + +#### What the model sees + +The generated [`ralph` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ralph) exposes one required `objective` string and one optional `maxRounds` number. Provider choice, handoff size, report schema, workflow script, and orchestration behavior are deployment-owned and absent from the call surface. + +#### Token effect + +Small fixed schema cost on each request where the tool is visible. + +#### KV Cache effect + +Prefix-stable while the definition and visibility are unchanged. + +### Child requests and parent result + +#### What the model sees + +Each child sees the standalone fixed round prompt plus the structured-output capture contract. The parent sees only the original call and one terminal result containing a worker-reported status, round count, and pretty-printed final report; intermediate child messages and reports do not enter the parent conversation. A failed ordinary child instead yields an error with its round number and, after round one, the last successful handoff. + +#### Token effect + +Every round pays for a fresh child context. `maxHandoffChars` bounds cross-round state and `maxResultChars` independently bounds the complete successful parent text; child work remains outside the parent context. + +#### KV Cache effect + +Each fresh child has an independent request cache. The parent result appends after the reusable request prefix. + +## Known Limitations and Deferred Work + +- **Completion is worker self-declaration** — there is no independent evaluator or verifier deciding whether the objective is actually complete; evaluator policy and evaluator-driven continuation are deferred. +- **Foreground only** — there is no task id, background collection, process-resume checkpoint, scheduler, or wall-clock start policy. +- **The workspace is the only cross-round long-term memory** — one bounded report is the explicit handoff, and uncommitted conversational reasoning disappears with each child. +- **One round is one fresh child** — there is no within-round fan-out, model/provider switching, fork context, or model-call-selected provider. +- **Ordinary child failure is terminal for the run** — the fixed script reports the failed round and last successful handoff but does not retry; fatal workflow infrastructure failures can end before that state is returned. +- **Only round count bounds aggregate effort** — token, price, and elapsed-time budgets are deferred. diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json new file mode 100644 index 0000000000..fb12147445 --- /dev/null +++ b/packages/workflow/tool-ralph/package.json @@ -0,0 +1,59 @@ +{ + "name": "@deepseek-ai/dsh-tool-ralph", + "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams", + "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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.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-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-workflow": "^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-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workflow": "workspace:^", + "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/workflow/tool-ralph/src/index.ts b/packages/workflow/tool-ralph/src/index.ts new file mode 100644 index 0000000000..0f23ae062a --- /dev/null +++ b/packages/workflow/tool-ralph/src/index.ts @@ -0,0 +1,456 @@ +/** + * Model-facing foreground Ralph loop over the workflow and subagent seams. A + * fixed script starts one fresh structured-output child per round, carrying + * only the immutable objective and the previous bounded handoff between them. + * @module @deepseek-ai/dsh-tool-ralph + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools' +import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow' +// Declaration merge only: makes ctx.systemPrompt visible for section registration. +import type {} from '@deepseek-ai/dsh-system-prompt' + +export const name = 'tool-ralph' +export const inject = ['tools', 'workflows', 'subagents', 'systemPrompt'] + +/** Deployment policy for the fixed Ralph workflow. */ +export interface Config { + /** Fresh structured-output provider used for every round (default `spawn`). */ + subagentProvider?: string + /** Default and deployment ceiling for one call's round count (default 256). */ + maxRounds?: number + /** Maximum serialized characters in one structured handoff (default 16384). */ + maxHandoffChars?: number + /** Maximum characters in a successful parent-facing terminal text (default 16384). */ + maxResultChars?: number +} + +/** Schemastery configuration for the Ralph tool. */ +export const Config: z = z.object({ + subagentProvider: z.string().default('spawn'), + maxRounds: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(256), + maxHandoffChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16_384), + maxResultChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16_384), +}) + +interface ResolvedConfig { + readonly subagentProvider: string + readonly maxRounds: number + readonly maxHandoffChars: number + readonly maxResultChars: number +} + +type RalphRoundStatus = 'continue' | 'complete' | 'blocked' + +interface RalphRoundReport { + readonly status: RalphRoundStatus + readonly summary: string + readonly evidence: string[] + readonly nextSteps: string[] + readonly blocker: string +} + +type RalphRunStatus = 'complete' | 'blocked' | 'budget-limited' + +interface RalphRunResult { + readonly status: RalphRunStatus + readonly roundsStarted: number + readonly report: RalphRoundReport +} + +interface RalphRoundFailure { + readonly status: 'round-failed' + readonly roundsStarted: number + readonly lastReport?: RalphRoundReport +} + +type RalphTerminalResult = RalphRunResult | RalphRoundFailure + +interface RalphCallArgs { + objective: string + maxRounds?: number +} + +const RALPH_META = { + name: 'ralph-loop', + description: 'Iterate toward one objective with a fresh child and bounded structured handoff per round.', + phases: [{ title: 'Fresh-agent rounds', detail: 'One clean child context per Ralph round.' }], +} + +/** + * Fixed, deployment-owned orchestration. The model supplies data only; it + * cannot alter the loop, provider route, schema, or handoff validation. + */ +const RALPH_SCRIPT = String.raw` +const reportSchema = { + type: 'object', + properties: { + status: { type: 'string', enum: ['continue', 'complete', 'blocked'] }, + summary: { type: 'string' }, + evidence: { type: 'array', items: { type: 'string' } }, + nextSteps: { type: 'array', items: { type: 'string' } }, + blocker: { type: 'string' }, + }, + required: ['status', 'summary', 'evidence', 'nextSteps', 'blocker'], + additionalProperties: false, +} + +function normalizedText(value) { + return typeof value === 'string' && value.length > 0 && value === value.trim() +} + +function normalizedList(value) { + return Array.isArray(value) && value.every(normalizedText) +} + +function validateReport(report) { + if (report === null || typeof report !== 'object' || Array.isArray(report)) { + throw new Error('Ralph child returned no structured round report') + } + if (!normalizedText(report.summary)) { + throw new Error('Ralph round report summary must be non-empty and normalized') + } + if (!normalizedList(report.evidence) || !normalizedList(report.nextSteps)) { + throw new Error('Ralph round report evidence and nextSteps must contain only non-empty normalized strings') + } + if (typeof report.blocker !== 'string' || report.blocker !== report.blocker.trim()) { + throw new Error('Ralph round report blocker must be a normalized string') + } + switch (report.status) { + case 'continue': + if (report.nextSteps.length === 0 || report.blocker !== '') { + throw new Error('a continuing Ralph report needs nextSteps and an empty blocker') + } + break + case 'complete': + if (report.evidence.length === 0 || report.nextSteps.length !== 0 || report.blocker !== '') { + throw new Error('a complete Ralph report needs evidence, no nextSteps, and an empty blocker') + } + break + case 'blocked': + if (!normalizedText(report.blocker)) { + throw new Error('a blocked Ralph report needs a concrete blocker') + } + break + default: + throw new Error('Ralph round report status is invalid') + } + const serialized = JSON.stringify(report) + if (serialized.length > args.maxHandoffChars) { + throw new Error('Ralph round report exceeds maxHandoffChars (' + serialized.length + ' > ' + args.maxHandoffChars + ')') + } + return report +} + +let previous +phase('Fresh-agent rounds') +for (let round = 1; round <= args.maxRounds; round += 1) { + const prior = previous === undefined ? '(none — this is the first round)' : JSON.stringify(previous) + const prompt = [ + 'You are one fresh worker in a foreground Ralph loop. You receive no parent conversation and no prior child session. Do not call the ralph tool: this round already is its worker.', + 'Immutable objective:\n' + args.objective, + 'Ralph round: ' + round + ' of ' + args.maxRounds + '.', + 'The shared workspace and its current working tree are the long-term memory and source of truth. Inspect them before acting, preserve existing work, perform concrete in-scope work, and verify what you change. Treat the previous report only as a bounded handoff; confirm it against the workspace.', + 'Previous structured handoff:\n' + prior, + 'Return one report with exact normalized strings. Use status continue with at least one nextSteps entry while useful work remains; complete only with concrete evidence and no nextSteps; blocked only when no meaningful progress is possible without human input or an external-state change. blocker must be empty unless blocked.', + ].join('\n\n') + const rawReport = await agent(prompt, { + label: 'Ralph round ' + round, + phase: 'Fresh-agent rounds', + schema: reportSchema, + }) + if (rawReport === null) { + return { status: 'round-failed', roundsStarted: round, lastReport: previous ?? null } + } + const report = validateReport(rawReport) + if (report.status === 'complete') return { status: 'complete', roundsStarted: round, report } + if (report.status === 'blocked') return { status: 'blocked', roundsStarted: round, report } + previous = report +} +return { status: 'budget-limited', roundsStarted: args.maxRounds, report: previous } +` + +const DESCRIPTION = 'Run a foreground fresh-agent Ralph loop toward one immutable objective. ' + + 'Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round ' + + 'opens a new child with no parent conversation or prior child session; the shared workspace is ' + + 'long-term memory, and only a bounded structured report crosses rounds. The call returns when ' + + 'a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work ' + + 'belongs to goal tools.' + +/** Validate defaults even when a caller invokes apply() without Loader normalization. */ +function resolveConfig(config: Config): ResolvedConfig { + const subagentProvider = config.subagentProvider ?? 'spawn' + const maxRounds = config.maxRounds ?? 256 + const maxHandoffChars = config.maxHandoffChars ?? 16_384 + const maxResultChars = config.maxResultChars ?? 16_384 + if (subagentProvider.length === 0 || subagentProvider !== subagentProvider.trim()) { + throw new TypeError('subagentProvider must be a non-empty normalized string') + } + if (!Number.isSafeInteger(maxRounds) || maxRounds < 1) { + throw new TypeError('maxRounds must be a positive safe integer') + } + if (!Number.isSafeInteger(maxHandoffChars) || maxHandoffChars < 1) { + throw new TypeError('maxHandoffChars must be a positive safe integer') + } + if (!Number.isSafeInteger(maxResultChars) || maxResultChars < 1) { + throw new TypeError('maxResultChars must be a positive safe integer') + } + return { subagentProvider, maxRounds, maxHandoffChars, maxResultChars } +} + +/** Resolve one model-selected cap against the deployment ceiling. */ +function resolveMaxRounds(requested: number | undefined, ceiling: number): number { + const value = requested ?? ceiling + if (!Number.isSafeInteger(value) || value < 1) { + throw new TypeError('Ralph maxRounds must be a positive safe integer') + } + if (value > ceiling) { + throw new TypeError(`Ralph maxRounds ${value} exceeds the deployment ceiling ${ceiling}`) + } + return value +} + +/** Require the configured route to mean a genuinely fresh structured child. */ +function requireFreshProvider(ctx: Context, name: string): SubagentProvider { + const provider = ctx.subagents.getProvider(name) + if (provider === undefined) { + throw new Error(`Ralph subagent provider "${name}" is not registered`) + } + if (!provider.capabilities.outputSchema) { + throw new Error(`Ralph subagent provider "${name}" does not support structured output`) + } + if (provider.inheritsParentContext) { + throw new Error(`Ralph subagent provider "${name}" inherits parent context; Ralph requires a fresh provider`) + } + return provider +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function normalizedText(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value === value.trim() +} + +function normalizedList(value: unknown): value is string[] { + return Array.isArray(value) && value.every(normalizedText) +} + +/** Defensively decode the fixed script's report across an implementation seam. */ +function readReport(value: unknown, expectedStatus: RalphRoundStatus, maxChars: number): RalphRoundReport { + if (!isRecord(value) + || Object.keys(value).sort().join(',') !== 'blocker,evidence,nextSteps,status,summary' + || value['status'] !== expectedStatus + || !normalizedText(value['summary']) + || !normalizedList(value['evidence']) + || !normalizedList(value['nextSteps']) + || typeof value['blocker'] !== 'string' + || value['blocker'] !== value['blocker'].trim()) { + throw new Error('Ralph workflow returned a malformed round report') + } + const report: RalphRoundReport = { + status: expectedStatus, + summary: value['summary'], + evidence: value['evidence'], + nextSteps: value['nextSteps'], + blocker: value['blocker'], + } + if (expectedStatus === 'continue' && (report.nextSteps.length === 0 || report.blocker !== '')) { + throw new Error('Ralph workflow returned an invalid continuing report') + } + if (expectedStatus === 'complete' + && (report.evidence.length === 0 || report.nextSteps.length !== 0 || report.blocker !== '')) { + throw new Error('Ralph workflow returned an invalid completion report') + } + if (expectedStatus === 'blocked' && !normalizedText(report.blocker)) { + throw new Error('Ralph workflow returned an invalid blocked report') + } + const chars = JSON.stringify(report).length + if (chars > maxChars) { + throw new Error(`Ralph workflow returned an oversized handoff (${chars} > ${maxChars})`) + } + return report +} + +/** Defensively decode the fixed script's terminal value. */ +function readRunResult(value: unknown, maxRounds: number, maxHandoffChars: number): RalphTerminalResult { + if (!isRecord(value) + || typeof value['roundsStarted'] !== 'number' + || !Number.isSafeInteger(value['roundsStarted']) + || value['roundsStarted'] < 1 + || value['roundsStarted'] > maxRounds) { + throw new Error('Ralph workflow returned a malformed terminal result') + } + const roundsStarted = value['roundsStarted'] + switch (value['status']) { + case 'complete': + if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') { + throw new Error('Ralph workflow returned a malformed terminal result') + } + return { status: 'complete', roundsStarted, report: readReport(value['report'], 'complete', maxHandoffChars) } + case 'blocked': + if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') { + throw new Error('Ralph workflow returned a malformed terminal result') + } + return { status: 'blocked', roundsStarted, report: readReport(value['report'], 'blocked', maxHandoffChars) } + case 'budget-limited': + if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') { + throw new Error('Ralph workflow returned a malformed terminal result') + } + if (roundsStarted !== maxRounds) { + throw new Error('Ralph workflow returned budget-limited before the round limit') + } + return { status: 'budget-limited', roundsStarted, report: readReport(value['report'], 'continue', maxHandoffChars) } + case 'round-failed': { + if (Object.keys(value).sort().join(',') !== 'lastReport,roundsStarted,status') { + throw new Error('Ralph workflow returned a malformed terminal result') + } + if (roundsStarted === 1) { + if (value['lastReport'] !== null) { + throw new Error('Ralph workflow returned an invalid first-round failure') + } + return { status: 'round-failed', roundsStarted } + } + if (value['lastReport'] === null) { + throw new Error('Ralph workflow returned a round failure without its last handoff') + } + return { + status: 'round-failed', + roundsStarted, + lastReport: readReport(value['lastReport'], 'continue', maxHandoffChars), + } + } + default: + throw new Error('Ralph workflow returned an unknown terminal status') + } +} + +/** A non-clean workflow finish is an error, never a partial Ralph success. */ +function stopReasonError(result: WorkflowResult): string | undefined { + switch (result.stopReason) { + case 'completed': + return undefined + case 'cancelled': + return `Ralph workflow was cancelled${result.error === undefined ? '' : ` (${result.error})`}` + case 'error': + return `Ralph workflow failed: ${result.error ?? 'unknown error'}` + /* v8 ignore start -- WorkflowStopReason is closed; a future variant must fail loud here. */ + default: + return `Ralph workflow ended abnormally (${String(result.stopReason satisfies never)})` + /* v8 ignore stop */ + } +} + +const TRUNCATION_NOTICE = '\n… [truncated]' + +/** Bound complete parent-facing text, including its envelope and truncation marker. */ +function boundResult(text: string, maxChars: number): string { + if (text.length <= maxChars) return text + if (maxChars <= TRUNCATION_NOTICE.length) return TRUNCATION_NOTICE.slice(0, maxChars) + return `${text.slice(0, maxChars - TRUNCATION_NOTICE.length)}${TRUNCATION_NOTICE}` +} + +/** Render the fixed terminal envelope without presenting self-report as certification. */ +function renderResult(result: RalphRunResult, maxChars: number): string { + const rounds = `${result.roundsStarted} round${result.roundsStarted === 1 ? '' : 's'}` + let text: string + switch (result.status) { + case 'complete': + text = `Ralph worker reported completion after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}` + break + case 'blocked': + text = `Ralph worker reported a blocker after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}` + break + case 'budget-limited': + text = `Ralph reached its ${rounds} limit; the worker reported work remaining.\nFinal report:\n${JSON.stringify(result.report, null, 2)}` + break + } + return boundResult(text, maxChars) +} + +/** Render an ordinary child failure with the most recent durable handoff. */ +function renderRoundFailure(result: RalphRoundFailure, maxChars: number): string { + const header = `Ralph round ${result.roundsStarted} child failed before producing a structured report.` + const text = result.lastReport === undefined + ? `${header}\nNo previous handoff was available.` + : `${header}\nLast successful handoff:\n${JSON.stringify(result.lastReport, null, 2)}` + return boundResult(text, maxChars) +} + +function presentCall(args: RalphCallArgs): ToolCallView { + return { card: 'generic', title: 'ralph', rawInput: args.objective } +} + +function presentResult(args: RalphCallArgs, result: { content: ContentBlock[]; isError: boolean }): ToolResultView { + void args + void result + return { card: 'generic' } +} + +/** Register the fixed Ralph tool and its explicit-ask usage policy. */ +export function apply(ctx: Context, config: Config): void { + const resolved = resolveConfig(config) + ctx.systemPrompt.section({ + name: 'tool:ralph', + order: 116, + text: 'Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.', + }) + ctx.tools.register(defineTool({ + name: 'ralph', + description: DESCRIPTION, + parameters: { + objective: { + type: 'string', + required: true, + description: 'The immutable completion objective for every fresh Ralph round.', + }, + maxRounds: { + type: 'number', + description: 'Optional positive safe-integer round cap, bounded by the deployment ceiling.', + }, + }, + async execute(args, exec): Promise { + const parent = exec.agent + if (parent === undefined) { + throw new Error('Ralph tool requires a calling agent (exec.agent was undefined)') + } + const objective = args.objective.trim() + if (objective.length === 0) throw new Error('Ralph objective must be a non-empty string') + const maxRounds = resolveMaxRounds(args.maxRounds, resolved.maxRounds) + void requireFreshProvider(ctx, resolved.subagentProvider) + + const run: WorkflowRun = ctx.workflows.start({ + script: RALPH_SCRIPT, + meta: RALPH_META, + args: { objective, maxRounds, maxHandoffChars: resolved.maxHandoffChars }, + subagentProvider: resolved.subagentProvider, + maxTotalAgents: maxRounds, + parent, + ...exec.signal === undefined ? {} : { signal: exec.signal }, + }) + const onAbort = (): void => { run.cancel('parent step aborted') } + exec.signal?.addEventListener('abort', onAbort, { once: true }) + if (exec.signal?.aborted) run.cancel('parent step aborted') + + try { + const settled = await run.result + const error = stopReasonError(settled) + if (error !== undefined) throw new Error(error) + const value = readRunResult(settled.value, maxRounds, resolved.maxHandoffChars) + if (value.status === 'round-failed') throw new Error(renderRoundFailure(value, resolved.maxResultChars)) + return [{ type: 'text', text: renderResult(value, resolved.maxResultChars) }] + } finally { + exec.signal?.removeEventListener('abort', onAbort) + await run.dispose() + } + }, + presentCall, + presentResult, + })) +} diff --git a/packages/workflow/tool-ralph/src/invariant.ts b/packages/workflow/tool-ralph/src/invariant.ts new file mode 100644 index 0000000000..22a7d1f2ea --- /dev/null +++ b/packages/workflow/tool-ralph/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-ralph`. + * @module @deepseek-ai/dsh-tool-ralph/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-ralph' + +/** Cordis companion plugin name. */ +export const name = 'tool-ralph-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this model-facing orchestration adapter owns no independent event stream; + * workflow and subagent owners validate the runs and child lifecycles it starts. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts new file mode 100644 index 0000000000..a5b44712e8 --- /dev/null +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { CallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import SubagentService from '@deepseek-ai/dsh-subagent' +import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' +import * as spawn from '@deepseek-ai/dsh-subagent-spawn' +import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' +import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as toolRalph from '../src/index.ts' + +type MockScript = ConstructorParameters[0] + +/** Mount the shipped Ralph execution stack around one keyless model script. */ +async function mountRalph(script: MockScript, config: toolRalph.Config) { + const ctx = new Context() + const adapter = new MockAdapter(script) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(spawn, { providerName: 'spawn' }) + await ctx.plugin(WorkerWorkflowEngine, {}) + await ctx.plugin(toolRalph, config) + ctx.llm.registerAdapter(['mock'], adapter) + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('ralph-parent'), + meta: { cwd: '/tmp/ralph-shared-workspace' }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + return { ctx, adapter, parentHandle, parent: parentHandle.agent } +} + +describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { + it('uses distinct empty-seed children, shared cwd, and only the prior bounded handoff', async () => { + const firstReport = { + status: 'continue', + summary: 'ROUND_ONE_HANDOFF', + evidence: ['Created migration-a.ts.'], + nextSteps: ['Finish migration-b.ts.'], + blocker: '', + } + const finalReport = { + status: 'complete', + summary: 'Both migration slices are complete.', + evidence: ['Focused migration tests pass.'], + nextSteps: [], + blocker: '', + } + const ctx = new Context() + const adapter = new MockAdapter([ + textResponse('PARENT_HISTORY_MARKER'), + toolCallResponse('round-1', STRUCTURED_OUTPUT_TOOL, firstReport), + toolCallResponse('round-2', STRUCTURED_OUTPUT_TOOL, finalReport), + ]) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(spawn, { providerName: 'spawn' }) + await ctx.plugin(WorkerWorkflowEngine, {}) + await ctx.plugin(toolRalph, { maxRounds: 2 }) + ctx.llm.registerAdapter(['mock'], adapter) + + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('ralph-parent'), + meta: { cwd: '/tmp/ralph-shared-workspace' }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const parent = parentHandle.agent + parent.send([{ type: 'text', text: 'PARENT_PROMPT_MARKER' }]) + await parent.whenIdle() + + const children: Agent[] = [] + const phases: string[] = [] + ctx.on('workflow/phase', (_run, title) => { phases.push(title) }) + ctx.on('workflow/agent-start', (_run, child) => { + const agent = ctx.agents.get(child.childId) + expect(agent).toBeDefined() + children.push(agent!) + }) + const result = await ctx.tools.execute({ + callId: CallId('ralph-integration'), + name: 'ralph', + arguments: { objective: 'Complete both migration slices.', maxRounds: 2 }, + agent: parent, + }) + + expect(result.isError).toBe(false) + expect((result.content[0] as { text: string }).text) + .toContain('Ralph worker reported completion after 2 rounds.') + expect(phases).toEqual(['Fresh-agent rounds']) + expect(children).toHaveLength(2) + expect(new Set(children.map(child => child.id)).size).toBe(2) + for (const child of children) { + expect(child.session.header.cwd).toBe('/tmp/ralph-shared-workspace') + expect(child.session.header.parentSession).toBe(parent.session.header.id) + expect(child.session.header.seedLength).toBeUndefined() + expect(ctx.agents.get(child.id)).toBeUndefined() + } + + expect(adapter.requests).toHaveLength(3) + const firstChildRequest = JSON.stringify(adapter.requests[1]!.messages) + const secondChildRequest = JSON.stringify(adapter.requests[2]!.messages) + expect(firstChildRequest).not.toContain('PARENT_PROMPT_MARKER') + expect(firstChildRequest).not.toContain('PARENT_HISTORY_MARKER') + expect(firstChildRequest).not.toContain('ROUND_ONE_HANDOFF') + expect(secondChildRequest).not.toContain('PARENT_PROMPT_MARKER') + expect(secondChildRequest).not.toContain('PARENT_HISTORY_MARKER') + expect(secondChildRequest).toContain('ROUND_ONE_HANDOFF') + + await parentHandle.dispose() + }) + + it('reports the failed round and last good handoff when a child fails', async () => { + const firstReport = { + status: 'continue', + summary: 'ROUND_ONE_HANDOFF', + evidence: ['Created migration-a.ts.'], + nextSteps: ['Finish migration-b.ts.'], + blocker: '', + } + const { ctx, parent, parentHandle } = await mountRalph([ + toolCallResponse('round-1', STRUCTURED_OUTPUT_TOOL, firstReport), + maxTokensResponse('unfinished child output'), + ], { maxRounds: 2 }) + const children: Agent[] = [] + ctx.on('workflow/agent-start', (_run, child) => { + const agent = ctx.agents.get(child.childId) + if (agent !== undefined) children.push(agent) + }) + + const result = await ctx.tools.execute({ + callId: CallId('ralph-child-failure'), + name: 'ralph', + arguments: { objective: 'Complete both migration slices.', maxRounds: 2 }, + agent: parent, + }) + + expect(result.isError).toBe(true) + const text = (result.content[0] as { text: string }).text + expect(text).toContain('Ralph round 2 child failed before producing a structured report.') + expect(text).toContain('Last successful handoff:') + expect(text).toContain('ROUND_ONE_HANDOFF') + expect(children).toHaveLength(2) + for (const child of children) expect(ctx.agents.get(child.id)).toBeUndefined() + await parentHandle.dispose() + }) + + it.each([ + { + name: 'blocked', + report: { + status: 'blocked', + summary: 'External authorization is required.', + evidence: ['The local implementation is ready.'], + nextSteps: ['Continue after authorization.'], + blocker: 'The required external authorization is unavailable.', + }, + config: { maxRounds: 2 }, + expectedError: false, + expectedText: 'Ralph worker reported a blocker after 1 round.', + }, + { + name: 'budget-limited', + report: { + status: 'continue', + summary: 'One slice is complete.', + evidence: ['The first focused test passes.'], + nextSteps: ['Implement the remaining slice.'], + blocker: '', + }, + config: { maxRounds: 1 }, + expectedError: false, + expectedText: 'Ralph reached its 1 round limit; the worker reported work remaining.', + }, + { + name: 'unnormalized report', + report: { + status: 'continue', + summary: ' padded summary ', + evidence: ['A focused test passes.'], + nextSteps: ['Continue implementation.'], + blocker: '', + }, + config: { maxRounds: 1 }, + expectedError: true, + expectedText: 'summary must be non-empty and normalized', + }, + { + name: 'invalid continuing report', + report: { + status: 'continue', + summary: 'Work remains.', + evidence: ['A focused test passes.'], + nextSteps: [], + blocker: '', + }, + config: { maxRounds: 1 }, + expectedError: true, + expectedText: 'a continuing Ralph report needs nextSteps and an empty blocker', + }, + { + name: 'oversized report', + report: { + status: 'continue', + summary: 'x'.repeat(300), + evidence: ['A focused test passes.'], + nextSteps: ['Continue implementation.'], + blocker: '', + }, + config: { maxRounds: 1, maxHandoffChars: 100 }, + expectedError: true, + expectedText: 'Ralph round report exceeds maxHandoffChars', + }, + ])('enforces the fixed script for $name', async ({ report, config, expectedError, expectedText }) => { + const { ctx, parent, parentHandle } = await mountRalph([ + toolCallResponse('round-report', STRUCTURED_OUTPUT_TOOL, report), + ], config) + + const result = await ctx.tools.execute({ + callId: CallId('ralph-script-enforcement'), + name: 'ralph', + arguments: { objective: 'Complete the scoped work.', maxRounds: config.maxRounds }, + agent: parent, + }) + + expect(result.isError).toBe(expectedError) + expect((result.content[0] as { text: string }).text).toContain(expectedText) + await parentHandle.dispose() + }) + + it('cancels the real worker and fresh child to quiescence', { timeout: 20_000 }, async () => { + const { ctx, parent, parentHandle } = await mountRalph(['hang'], { maxRounds: 2 }) + const children: Agent[] = [] + const outcomes: string[] = [] + let resolveChildStarted!: (child: Agent) => void + const childStarted = new Promise((resolve) => { resolveChildStarted = resolve }) + ctx.on('workflow/agent-start', (_run, child) => { + const agent = ctx.agents.get(child.childId) + if (agent !== undefined) { + children.push(agent) + resolveChildStarted(agent) + } + }) + ctx.on('workflow/agent-end', (_run, child) => { outcomes.push(child.outcome) }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('ralph-real-cancel'), + name: 'ralph', + arguments: { objective: 'Keep working until cancelled.', maxRounds: 2 }, + agent: parent, + signal: controller.signal, + }) + await childStarted + + controller.abort() + const result = await pending + + expect(result.isError).toBe(true) + expect((result.content[0] as { text: string }).text).toContain('Ralph workflow was cancelled') + expect(outcomes).toEqual(['cancelled']) + expect(ctx.agents.get(children[0]!.id)).toBeUndefined() + await parentHandle.dispose() + }) +}) diff --git a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts new file mode 100644 index 0000000000..119da3def3 --- /dev/null +++ b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts @@ -0,0 +1,380 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubagentCapabilities, SubagentProvider, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow' +import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' +import * as toolRalph from '../src/index.ts' + +class StubEngine extends WorkflowService { + requests: WorkflowStartRequest[] = [] + cancels: string[] = [] + disposed = 0 + settle!: (result: WorkflowResult) => void + startError: Error | undefined + + start(request: WorkflowStartRequest): WorkflowRun { + if (this.startError !== undefined) throw this.startError + this.requests.push(request) + const result = new Promise((resolve) => { this.settle = resolve }) + return { + id: WorkflowRunId(`ralph-${this.requests.length}`), + meta: request.meta, + result, + cancel: (reason?: string) => { + this.cancels.push(reason ?? 'cancelled') + this.settle({ + value: null, + stopReason: 'cancelled', + ...reason === undefined ? {} : { error: reason }, + agentsStarted: 0, + }) + }, + dispose: () => { + this.disposed += 1 + return Promise.resolve() + }, + } + } +} + +class StubProvider implements SubagentProvider { + readonly name = 'fresh' + readonly capabilities: SubagentCapabilities + readonly inheritsParentContext: boolean + + constructor(options?: { outputSchema?: boolean; inheritsParentContext?: boolean }) { + this.capabilities = { + outputSchema: options?.outputSchema ?? true, + depthLimit: true, + toolFilter: true, + persona: true, + } + this.inheritsParentContext = options?.inheritsParentContext ?? false + } + + start(_request: SubagentStartRequest): Promise { + return Promise.reject(new Error('StubProvider.start must not be reached behind StubEngine')) + } +} + +interface SetupOptions { + config?: toolRalph.Config + provider?: StubProvider | false +} + +async function setup(options?: SetupOptions) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + const provider = options?.provider === false ? undefined : options?.provider ?? new StubProvider() + if (provider !== undefined) ctx.subagents.registerProvider(provider) + await ctx.plugin(StubEngine) + const config: toolRalph.Config = { subagentProvider: 'fresh' } + if (options?.config?.subagentProvider !== undefined) config.subagentProvider = options.config.subagentProvider + if (options?.config?.maxRounds !== undefined) config.maxRounds = options.config.maxRounds + if (options?.config?.maxHandoffChars !== undefined) config.maxHandoffChars = options.config.maxHandoffChars + if (options?.config?.maxResultChars !== undefined) config.maxResultChars = options.config.maxResultChars + const fiber = await ctx.plugin(toolRalph, config) + const parent = { id: SessionId('caller'), options: {} } as unknown as Agent + return { ctx, engine: ctx.workflows as StubEngine, parent, fiber } +} + +function execute( + ctx: Context, + args: unknown, + extra?: { agent?: Agent; signal?: AbortSignal }, +): Promise { + return ctx.tools.execute({ + callId: CallId('ralph-call'), + name: 'ralph', + arguments: args, + ...extra?.agent === undefined ? {} : { agent: extra.agent }, + ...extra?.signal === undefined ? {} : { signal: extra.signal }, + }) +} + +const CONTINUE = { + status: 'continue', + summary: 'Implemented the first slice.', + evidence: ['Focused tests pass.'], + nextSteps: ['Implement the second slice.'], + blocker: '', +} + +const COMPLETE = { + status: 'complete', + summary: 'The objective is complete.', + evidence: ['All required gates pass.'], + nextSteps: [], + blocker: '', +} + +const BLOCKED = { + status: 'blocked', + summary: 'No local work can progress.', + evidence: ['The required remote service is unavailable.'], + nextSteps: ['Retry after service recovery.'], + blocker: 'The required remote service is unavailable.', +} + +async function settleCompleted( + engine: StubEngine, + pending: Promise, + value: unknown, + agentsStarted = 1, +): Promise { + await vi.waitFor(() => { expect(engine.requests.length).toBeGreaterThan(0) }) + engine.settle({ value, stopReason: 'completed', agentsStarted }) + return pending +} + +describe('dsh-tool-ralph', () => { + it('starts the fixed workflow through the configured fresh provider and renders completion', async () => { + const { ctx, engine, parent } = await setup({ config: { maxRounds: 9, maxHandoffChars: 9000 } }) + const pending = execute(ctx, { objective: ' Finish the migration. ', maxRounds: 4 }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + expect(engine.requests[0]).toMatchObject({ + meta: { name: 'ralph-loop' }, + args: { objective: 'Finish the migration.', maxRounds: 4, maxHandoffChars: 9000 }, + subagentProvider: 'fresh', + maxTotalAgents: 4, + parent, + }) + expect(engine.requests[0]!.script).toContain("status: 'budget-limited'") + const result = await settleCompleted(engine, pending, { + status: 'complete', + roundsStarted: 1, + report: COMPLETE, + }) + expect(result.isError).toBe(false) + expect((result.content[0] as { text: string }).text) + .toContain('Ralph worker reported completion after 1 round.') + expect((result.content[0] as { text: string }).text).toContain('All required gates pass.') + expect(engine.disposed).toBe(1) + }) + + it('renders blocked and budget-limited terminal outcomes as bounded successful results', async () => { + const { ctx, engine, parent } = await setup({ config: { maxRounds: 2 } }) + const blocked = execute(ctx, { objective: 'Ship it.' }, { agent: parent }) + const blockedResult = await settleCompleted(engine, blocked, { + status: 'blocked', + roundsStarted: 2, + report: BLOCKED, + }, 2) + expect((blockedResult.content[0] as { text: string }).text) + .toContain('Ralph worker reported a blocker after 2 rounds.') + + const limited = execute(ctx, { objective: 'Ship it.' }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) }) + const limitedResult = await settleCompleted(engine, limited, { + status: 'budget-limited', + roundsStarted: 2, + report: CONTINUE, + }, 2) + expect((limitedResult.content[0] as { text: string }).text) + .toContain('Ralph reached its 2 rounds limit; the worker reported work remaining.') + }) + + it('bounds the complete parent result and labels worker-reported completion', async () => { + const { ctx, engine, parent } = await setup({ config: { maxResultChars: 160 } }) + const pending = execute(ctx, { objective: 'Ship it.' }, { agent: parent }) + const result = await settleCompleted(engine, pending, { + status: 'complete', + roundsStarted: 1, + report: { ...COMPLETE, evidence: ['x'.repeat(500)] }, + }) + const text = (result.content[0] as { text: string }).text + expect(text).toHaveLength(160) + expect(text).toContain('Ralph worker reported completion after 1 round.') + expect(text).toMatch(/… \[truncated\]$/) + }) + + it('honors a result limit shorter than the truncation marker', async () => { + const { ctx, engine, parent } = await setup({ config: { maxResultChars: 5 } }) + const result = await settleCompleted(engine, execute(ctx, { objective: 'Ship it.' }, { agent: parent }), { + status: 'complete', + roundsStarted: 1, + report: COMPLETE, + }) + expect((result.content[0] as { text: string }).text).toBe('\n… [t') + }) + + it('reports an ordinary child failure with the failed round and last durable handoff', async () => { + const { ctx, engine, parent } = await setup({ config: { maxRounds: 2 } }) + const first = execute(ctx, { objective: 'Ship it.', maxRounds: 2 }, { agent: parent }) + const firstResult = await settleCompleted(engine, first, { + status: 'round-failed', + roundsStarted: 1, + lastReport: null, + }) + expect(firstResult.isError).toBe(true) + expect((firstResult.content[0] as { text: string }).text).toContain('Ralph round 1 child failed') + expect((firstResult.content[0] as { text: string }).text).toContain('No previous handoff was available.') + + const later = execute(ctx, { objective: 'Ship it.', maxRounds: 2 }, { agent: parent }) + const laterResult = await settleCompleted(engine, later, { + status: 'round-failed', + roundsStarted: 2, + lastReport: CONTINUE, + }) + expect(laterResult.isError).toBe(true) + expect((laterResult.content[0] as { text: string }).text).toContain('Ralph round 2 child failed') + expect((laterResult.content[0] as { text: string }).text).toContain('Implemented the first slice.') + }) + + it('maps workflow error and cancellation reasons to tool errors and always disposes', async () => { + const { ctx, engine, parent } = await setup() + const failed = execute(ctx, { objective: 'Work.' }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + engine.settle({ value: null, stopReason: 'error', error: 'child report malformed', agentsStarted: 1 }) + expect(((await failed).content[0] as { text: string }).text) + .toContain('Ralph workflow failed: child report malformed') + + const unknown = execute(ctx, { objective: 'Work.' }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) }) + engine.settle({ value: null, stopReason: 'error', agentsStarted: 0 }) + expect(((await unknown).content[0] as { text: string }).text).toContain('unknown error') + + const cancelled = execute(ctx, { objective: 'Work.' }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(3) }) + engine.settle({ value: null, stopReason: 'cancelled', error: 'user stopped', agentsStarted: 0 }) + expect(((await cancelled).content[0] as { text: string }).text).toContain('cancelled (user stopped)') + + const bare = execute(ctx, { objective: 'Work.' }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(4) }) + engine.settle({ value: null, stopReason: 'cancelled', agentsStarted: 0 }) + expect(((await bare).content[0] as { text: string }).text).toMatch(/cancelled$/) + expect(engine.disposed).toBe(4) + }) + + it('bridges mid-flight and already-aborted parent signals to cancellation', async () => { + const { ctx, engine, parent } = await setup() + const controller = new AbortController() + const pending = execute(ctx, { objective: 'Work.' }, { agent: parent, signal: controller.signal }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + controller.abort() + expect((await pending).isError).toBe(true) + + const already = new AbortController() + already.abort() + expect((await execute(ctx, { objective: 'Work.' }, { agent: parent, signal: already.signal })).isError).toBe(true) + expect(engine.cancels).toEqual(['parent step aborted', 'parent step aborted']) + expect(engine.disposed).toBe(2) + }) + + it('rejects absent authority, empty objectives, bad round caps, and schema-invalid calls before start', async () => { + const { ctx, engine, parent } = await setup({ config: { maxRounds: 3 } }) + expect((await execute(ctx, { objective: 'Work.' })).isError).toBe(true) + expect((await execute(ctx, { objective: ' ' }, { agent: parent })).isError).toBe(true) + for (const maxRounds of [0, 1.5, Number.NaN, 4]) { + expect((await execute(ctx, { objective: 'Work.', maxRounds }, { agent: parent })).isError).toBe(true) + } + const missing = await execute(ctx, {}, { agent: parent }) + expect(missing.error?.code).toBe('INVALID_ARGS') + expect(engine.requests).toHaveLength(0) + }) + + it('rejects missing, unstructured, and parent-context-inheriting provider routes', async () => { + const missing = await setup({ provider: false }) + expect(((await execute(missing.ctx, { objective: 'Work.' }, { agent: missing.parent })).content[0] as { text: string }).text) + .toContain('is not registered') + expect(missing.engine.requests).toHaveLength(0) + + const unstructured = await setup({ provider: new StubProvider({ outputSchema: false }) }) + expect(((await execute(unstructured.ctx, { objective: 'Work.' }, { agent: unstructured.parent })).content[0] as { text: string }).text) + .toContain('does not support structured output') + + const inherited = await setup({ provider: new StubProvider({ inheritsParentContext: true }) }) + expect(((await execute(inherited.ctx, { objective: 'Work.' }, { agent: inherited.parent })).content[0] as { text: string }).text) + .toContain('inherits parent context') + }) + + it('rejects invalid direct-apply config before touching injected services', () => { + expect(() => { toolRalph.apply(new Context(), { subagentProvider: ' ' }) }).toThrow('non-empty normalized') + expect(() => { toolRalph.apply(new Context(), { maxRounds: 0 }) }).toThrow('positive safe integer') + expect(() => { toolRalph.apply(new Context(), { maxHandoffChars: 1.5 }) }).toThrow('positive safe integer') + expect(() => { toolRalph.apply(new Context(), { maxResultChars: 0 }) }).toThrow('positive safe integer') + }) + + it('turns malformed fixed-workflow terminal values and reports into errors', async () => { + const cases: { value: unknown; message: string; config?: toolRalph.Config }[] = [ + { value: null, message: 'malformed terminal result' }, + { value: { status: 'complete', roundsStarted: 0, report: COMPLETE }, message: 'malformed terminal result' }, + { value: { status: 'complete', roundsStarted: 3, report: COMPLETE }, message: 'malformed terminal result', config: { maxRounds: 2 } }, + { value: { status: 'mystery', roundsStarted: 1, report: COMPLETE }, message: 'unknown terminal status' }, + { value: { status: 'budget-limited', roundsStarted: 1, report: CONTINUE }, message: 'before the round limit', config: { maxRounds: 2 } }, + { value: { status: 'complete', roundsStarted: 1, report: null }, message: 'malformed round report' }, + { value: { status: 'complete', roundsStarted: 1, report: COMPLETE, extra: true }, message: 'malformed terminal result' }, + { value: { status: 'blocked', roundsStarted: 1, report: BLOCKED, extra: true }, message: 'malformed terminal result' }, + { value: { status: 'budget-limited', roundsStarted: 1, report: CONTINUE, extra: true }, message: 'malformed terminal result', config: { maxRounds: 1 } }, + { value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, status: 'continue' } }, message: 'malformed round report' }, + { value: { status: 'budget-limited', roundsStarted: 1, report: { ...CONTINUE, nextSteps: [] } }, message: 'invalid continuing report', config: { maxRounds: 1 } }, + { value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, evidence: [] } }, message: 'invalid completion report' }, + { value: { status: 'blocked', roundsStarted: 1, report: { ...BLOCKED, blocker: '' } }, message: 'invalid blocked report' }, + { value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, summary: 'x'.repeat(500) } }, message: 'oversized handoff', config: { maxHandoffChars: 100 } }, + { value: { status: 'round-failed', roundsStarted: 1 }, message: 'malformed terminal result' }, + { value: { status: 'round-failed', roundsStarted: 1, lastReport: CONTINUE }, message: 'invalid first-round failure' }, + { value: { status: 'round-failed', roundsStarted: 2, lastReport: null }, message: 'without its last handoff', config: { maxRounds: 2 } }, + { value: { status: 'round-failed', roundsStarted: 2, lastReport: { ...CONTINUE, nextSteps: [] } }, message: 'invalid continuing report', config: { maxRounds: 2 } }, + ] + for (const testCase of cases) { + const { ctx, engine, parent } = await setup( + testCase.config === undefined ? undefined : { config: testCase.config }, + ) + const result = await settleCompleted( + engine, + execute(ctx, { objective: 'Work.', ...testCase.config?.maxRounds === undefined ? {} : { maxRounds: testCase.config.maxRounds } }, { agent: parent }), + testCase.value, + ) + expect(result.isError).toBe(true) + expect((result.content[0] as { text: string }).text).toContain(testCase.message) + } + }) + + it('surfaces a synchronous engine start failure without inventing a run', async () => { + const { ctx, engine, parent } = await setup() + engine.startError = new Error('engine refused fixed script') + const result = await execute(ctx, { objective: 'Work.' }, { agent: parent }) + expect(result.isError).toBe(true) + expect((result.content[0] as { text: string }).text).toContain('engine refused fixed script') + expect(engine.disposed).toBe(0) + }) + + it('registers scoped guidance and pure replay-safe generic presentation', async () => { + const { ctx, fiber } = await setup() + const section = (await ctx.systemPrompt.assemble()).sections.find(candidate => candidate.name === 'tool:ralph') + expect(section?.text).toContain('ONLY when the direct human explicitly asks') + expect(section?.text).toContain('worker reports, not independent evaluation') + const tool = ctx.tools.get('ralph')! + expect(tool.description).toContain('worker reports completion') + expect(tool.presentCall!({ objective: 'Finish it.' })).toEqual({ + card: 'generic', + title: 'ralph', + rawInput: 'Finish it.', + }) + expect(tool.presentResult!({ objective: 'Finish it.' }, { content: [], isError: false })).toEqual({ card: 'generic' }) + expect(tool.presentCall!({ nope: true })).toBeUndefined() + await fiber.dispose() + expect(ctx.tools.get('ralph')).toBeUndefined() + expect((await ctx.systemPrompt.assemble()).sections.some(candidate => candidate.name === 'tool:ralph')).toBe(false) + }) + + it('has the namespace-plugin export shape', () => { + expect('default' in toolRalph).toBe(false) + expect(toolRalph.name).toBe('tool-ralph') + expect(toolRalph.inject).toEqual(['tools', 'workflows', 'subagents', 'systemPrompt']) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(toolRalph) as Record + expect(unwrapped).toBe(toolRalph) + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/workflow/tool-ralph/tsconfig.json b/packages/workflow/tool-ralph/tsconfig.json new file mode 100644 index 0000000000..d69119b10b --- /dev/null +++ b/packages/workflow/tool-ralph/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../subagent/subagent" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/tools" + }, + { + "path": "../workflow" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 08bc8171c3..d8c72ed567 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -230,6 +230,12 @@ describe('dsh-tool-workflow', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'spawn', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + inheritsParentContext: false, + start: () => Promise.reject(new Error('the parked-script fixture must not start a child')), + }) await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 }) await ctx.plugin(toolWorkflow, {}) const parent = { id: SessionId('caller'), options: {} } as unknown as Agent diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index f7fcd6e6d2..74eacc5fd8 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -34,12 +34,12 @@ Unknown options, malformed arguments, unsupported schemas, tripped caps, provide ## Run sequence -`start()` validates meta and parses the body, creates the worker, and returns a holder-owned `WorkflowRun`. Source mode installs TypeScript transforms through a data-URL bootstrap; built mode passes sibling `lib/worker.cjs` as a filesystem path because pkg's VFS hook expects CommonJS. Both work under ordinary Node. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice. +`start()` validates meta, parses the body, resolves a registered normalized provider route, and resolves any per-run total-child cap before creating a worker or publishing `workflow/start`. A requested `maxTotalAgents` must be a positive safe integer no greater than the engine's configured deployment ceiling. Source mode installs TypeScript transforms through a data-URL bootstrap; built mode passes sibling `lib/worker.cjs` as a filesystem path because pkg's VFS hook expects CommonJS. Both work under ordinary Node. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice. For each `agent()` call: 1. The worker sends `child-start` with a plain-data prompt and options. -2. The host calls the configured provider through async `SubagentService.start`, passing the workflow's parent and one canonical per-run abort signal. +2. The host calls the start request's provider override, or otherwise the configured provider, through async `SubagentService.start`, passing the workflow's parent and one canonical per-run abort signal. Provider choice applies to every child in that run and is not visible to the script. 3. If start rejects, the host sends `child-start-error`; provider startup has already reached quiescence and no child lifecycle event is emitted. 4. If start fulfills while the workflow still admits work, the host records the run, observes `result`, then sends `child-started`. Even an already-settled result is forwarded afterward, preserving start-before-result order. 5. The worker emits paired `workflow/agent-start` and `workflow/agent-end` narration and requests child disposal after collection. @@ -81,6 +81,8 @@ The host keeps a ledger of forwarded child starts. A graceful worker supplies th | `syncTimeoutMs` | `5000` | VM timeout for the script's initial synchronous slice. | | `disposeGraceMs` | `5000` | Bound before force-settlement/termination and for public disposal. | +An owning consumer may set `WorkflowStartRequest.subagentProvider` and `WorkflowStartRequest.maxTotalAgents` for one run. These are engine-level policy, not script hooks or model-facing options; the ordinary `workflow` tool leaves both unset. A per-run total-child cap may lower but never raise the configured `maxTotalAgents` ceiling. + ## Model Experience ### Child-agent requests diff --git a/packages/workflow/workflow-workerthread/src/index.ts b/packages/workflow/workflow-workerthread/src/index.ts index 9fed7d3450..33c5917acf 100644 --- a/packages/workflow/workflow-workerthread/src/index.ts +++ b/packages/workflow/workflow-workerthread/src/index.ts @@ -73,6 +73,36 @@ function assertBodyParses(body: string, name: string): void { } } +/** Resolve one run's provider route before publishing work. */ +function resolveSubagentProvider(ctx: Context, configured: string, override: string | undefined): string { + const provider = override ?? configured + if (provider.length === 0 || provider !== provider.trim()) { + throw new WorkflowError( + 'workflow subagentProvider must be a non-empty normalized string', + 'INVALID_ARGUMENT', + ) + } + if (ctx.subagents.getProvider(provider) === undefined) { + throw new WorkflowError(`no subagent provider registered for "${provider}"`, 'AGENT_START') + } + return provider +} + +/** Resolve one run's total-child cap against the engine deployment ceiling. */ +function resolveMaxTotalAgents(requested: number | undefined, ceiling: number): number { + if (requested === undefined) return ceiling + if (!Number.isSafeInteger(requested) || requested < 1) { + throw new WorkflowError('workflow maxTotalAgents must be a positive safe integer', 'INVALID_ARGUMENT') + } + if (requested > ceiling) { + throw new WorkflowError( + `workflow maxTotalAgents ${requested} exceeds the engine ceiling ${ceiling}`, + 'INVALID_ARGUMENT', + ) + } + return requested +} + /** * The worker-thread engine service. `start()` validates the script up front * (meta + a host-side body parse) and returns a {@link WorkflowRun} whose @@ -113,13 +143,15 @@ class WorkerWorkflowEngine extends WorkflowService { start(request: WorkflowStartRequest): WorkflowRun { const meta = validateMeta(request.meta) assertBodyParses(request.script, meta.name) + const subagentProvider = resolveSubagentProvider(this.ctx, this.config.provider, request.subagentProvider) + const maxTotalAgents = resolveMaxTotalAgents(request.maxTotalAgents, this.config.maxTotalAgents) const id = WorkflowRunId(randomUUID()) const info: WorkflowRunInfo = { id, meta } const limits: WorkerLimits = { maxConcurrentAgents: this.config.maxConcurrentAgents === 0 ? Math.min(16, Math.max(1, availableParallelism() - 2)) : this.config.maxConcurrentAgents, - maxTotalAgents: this.config.maxTotalAgents, + maxTotalAgents, maxItemsPerCall: this.config.maxItemsPerCall, syncTimeoutMs: this.config.syncTimeoutMs, } @@ -144,7 +176,7 @@ class WorkerWorkflowEngine extends WorkflowService { meta, request.parent, init, - this.config.provider, + subagentProvider, this.config.disposeGraceMs, { phase: (title) => { this.emitWorkflowEvent('workflow/phase', info, title) }, diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index d82eae699d..535917fc42 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -255,7 +255,7 @@ export class WorkflowExecution { const opts = this.readAgentOptions(rawOpts) if (this.started >= this.limits.maxTotalAgents) { throw new WorkflowError( - `this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise maxTotalAgents in the engine config if the scale is intentional`, + `this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise the applicable maxTotalAgents limit if the scale is intentional`, 'AGENT_CAP', ) } diff --git a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts index e9d9a9b4e1..e330b3ee83 100644 --- a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts @@ -27,16 +27,30 @@ import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' const ctx = new Context() await ctx.plugin(SubagentService) -await ctx.plugin(WorkerWorkflowEngine, {}) +let selectedStarts = 0 +ctx.subagents.registerProvider({ + name: 'built-selected', + capabilities: { outputSchema: true, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + async start() { + selectedStarts += 1 + return { + id: 'built-child', + result: Promise.resolve({ output: [], structured: { answer: 42 }, stopReason: 'completed' }), + dispose: () => Promise.resolve(), + } + }, +}) +await ctx.plugin(WorkerWorkflowEngine, { provider: 'must-not-be-used' }) const run = ctx.workflows.start({ - script: 'return 6 * 7', + script: "const value = await agent('answer', { schema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] } }); return value.answer", meta: { name: 'built-smoke', description: 'built worker smoke' }, - // A zero-agent script never touches the provider. + subagentProvider: 'built-selected', parent: { id: 'built-smoke-parent', options: {} }, }) const result = await run.result await run.dispose() -if (result.stopReason !== 'completed' || result.value !== 42) { +if (result.stopReason !== 'completed' || result.value !== 42 || selectedStarts !== 1) { console.error('unexpected result: ' + JSON.stringify(result)) process.exit(1) } diff --git a/packages/workflow/workflow-workerthread/tests/session.spec.ts b/packages/workflow/workflow-workerthread/tests/session.spec.ts index 102bcbd9a0..b856a48785 100644 --- a/packages/workflow/workflow-workerthread/tests/session.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/session.spec.ts @@ -392,6 +392,7 @@ describe('runWorkerSession over an in-process MessageChannel', () => { const result = await host.result() expect(result.stopReason).toBe('error') expect(result.error).toContain('total agent cap (2)') + expect(result.error).toContain('applicable maxTotalAgents limit') expect(result.agentsStarted).toBe(2) host.close() }) diff --git a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts index 673a43ee0a..3fb08bb5ba 100644 --- a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts @@ -8,6 +8,7 @@ import { expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' import WorkerWorkflowEngine from '../src/index.ts' import { SessionId } from '@deepseek-ai/dsh-session' @@ -18,6 +19,13 @@ vi.setConfig({ testTimeout: 30_000 }) it('runs the default config through the source worker', async () => { const ctx = new Context() const subagents = await ctx.plugin(SubagentService) + const provider: SubagentProvider = { + name: 'spawn', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + inheritsParentContext: false, + start: () => Promise.reject(new Error('source-worker compat script must not start a child')), + } + ctx.subagents.registerProvider(provider) const engine = await ctx.plugin(WorkerWorkflowEngine, {}) const parent = { id: SessionId('workflow-compat-parent'), options: {} } as unknown as Agent try { diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 2f52cd7b6b..d8d6f1e09d 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -6,7 +6,7 @@ import Loader from '@cordisjs/plugin-loader' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' +import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRun, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import * as workerEngineModule from '../src/index.ts' import WorkerWorkflowEngine, { type Config } from '../src/index.ts' import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts' @@ -232,6 +232,97 @@ describe('dsh-workflow-workerthread', () => { expect(provider.runs[0]!.request.agentOptions).toEqual({ provider: 'openai' }) }) + it('a start-request provider override selects every child without changing the engine default', async () => { + const { ctx, parent, provider } = await setup() + const selected = new StubProvider('selected', () => text('selected reply')) + ctx.subagents.registerProvider(selected) + + const overridden = ctx.workflows.start({ + ...scripted("return await agent('route this run')"), + parent, + subagentProvider: 'selected', + }) + expect((await overridden.result).value).toBe('selected reply') + await overridden.dispose() + expect(selected.runs).toHaveLength(1) + expect(provider.runs).toHaveLength(0) + + const ordinary = await run(ctx, parent, scripted("return await agent('use the default')")) + expect(ordinary.value).toBe('stub reply') + expect(provider.runs).toHaveLength(1) + }) + + it('rejects invalid start-request provider routes before publishing a run', async () => { + const { ctx, parent } = await setup() + let starts = 0 + ctx.on('workflow/start', () => { starts += 1 }) + const messages: string[] = [] + for (const subagentProvider of ['', 'missing']) { + let run: WorkflowRun | undefined + let thrown: unknown + try { + run = ctx.workflows.start({ + ...scripted("return 'must not start'"), + parent, + subagentProvider, + }) + } catch (error: unknown) { + thrown = error + } + await run?.dispose() + messages.push(thrown instanceof Error ? thrown.message : '') + } + + expect(messages).toEqual([ + 'workflow subagentProvider must be a non-empty normalized string', + 'no subagent provider registered for "missing"', + ]) + expect(starts).toBe(0) + }) + + it('rejects invalid per-run total-agent caps before publishing a run', async () => { + const { ctx, parent } = await setup({ config: { maxTotalAgents: 2 } }) + let starts = 0 + ctx.on('workflow/start', () => { starts += 1 }) + const errors: unknown[] = [] + for (const maxTotalAgents of [0, 1.5, Number.NaN, 3]) { + try { + const handle = ctx.workflows.start({ + ...scripted("return 'must not start'"), + parent, + maxTotalAgents, + }) + await handle.dispose() + } catch (error: unknown) { + errors.push(error) + } + } + + expect(errors.slice(0, 3)).toEqual(Array(3).fill(expect.objectContaining({ + code: 'INVALID_ARGUMENT', + message: 'workflow maxTotalAgents must be a positive safe integer', + }))) + expect(errors[3]).toMatchObject({ + code: 'INVALID_ARGUMENT', + message: 'workflow maxTotalAgents 3 exceeds the engine ceiling 2', + }) + expect(starts).toBe(0) + }) + + it('enforces a per-run total-agent cap below the engine ceiling', async () => { + const { ctx, parent } = await setup({ config: { maxTotalAgents: 2 } }) + const handle = ctx.workflows.start({ + ...scripted("await agent('first'); await agent('second'); return 'unreachable'"), + parent, + maxTotalAgents: 1, + }) + const result = await handle.result + expect(result.stopReason).toBe('error') + expect(result.agentsStarted).toBe(1) + expect(result.error).toContain('total agent cap (1)') + await handle.dispose() + }) + it('a fatal hook error inside the worker kills the script and reports the error', async () => { const { ctx, parent } = await setup() const result = await run(ctx, parent, scripted("return await parallel([() => agent('x', { isolation: 'worktree' })])")) @@ -239,11 +330,18 @@ describe('dsh-workflow-workerthread', () => { expect(result.error).toContain('"isolation" is deferred') }) - it('a provider start failure crosses back as a fatal AGENT_START error', async () => { + it('rejects an unregistered configured provider before publishing a run', async () => { const { ctx, parent } = await setup({ config: { provider: 'nonexistent' } }) - const result = await run(ctx, parent, scripted("return await pipeline([1], () => agent('p'))")) - expect(result.stopReason).toBe('error') - expect(result.error).toContain('agent() could not start a child') + let thrown: unknown + try { + ctx.workflows.start({ ...scripted("return 'must not start'"), parent }) + } catch (error: unknown) { + thrown = error + } + expect(thrown).toMatchObject({ + code: 'AGENT_START', + message: 'no subagent provider registered for "nonexistent"', + }) }) it('waits for async provider start before announcing a result that settled early', async () => { diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md index 331ae36e8d..9283f22cc7 100644 --- a/packages/workflow/workflow/README.md +++ b/packages/workflow/workflow/README.md @@ -6,11 +6,11 @@ The workflow seam (`ctx.workflows`) executes a model-written orchestration scrip ## Service and run contract -`WorkflowService.start(request): WorkflowRun` validates enough synchronously to reject a malformed meta block or unparseable script before a run exists. Once returned, `WorkflowRun.result` never rejects: execution failures resolve with `stopReason: 'error'`, and cancellation resolves with `cancelled` within the engine's bounded grace. +`WorkflowService.start(request): WorkflowRun` validates enough synchronously to reject a malformed meta block, unparseable script, unavailable provider route, or unsupported per-run limit before a run exists. Once returned, `WorkflowRun.result` never rejects: execution failures resolve with `stopReason: 'error'`, and cancellation resolves with `cancelled` within the engine's bounded grace. A run is holder-owned. Engine-plugin unload prevents new starts but does not revoke accepted runs. The holder must call `dispose()` on every path; disposal cancels remaining work and reaches or abandons quiescence within the documented bound. -`WorkflowStartRequest` contains `{ meta, script, args?, parent, signal? }`. `parent` attributes every child agent to the invoking agent. `meta` and `args` are plain data, not script fragments. +`WorkflowStartRequest` contains `{ meta, script, args?, subagentProvider?, maxTotalAgents?, parent, signal? }`. `parent` attributes every child agent to the invoking agent. `subagentProvider` optionally routes every child in that run without exposing provider choice to the script; omission uses the engine's configured provider. `maxTotalAgents` optionally lowers the engine's deployment ceiling for one run and is likewise invisible to the script. An implementation rejects invalid routes and limits synchronously. `meta` and `args` are plain data, not script fragments. `WorkflowRun` exposes `{ id, meta, result, cancel(reason?), dispose() }`. `WorkflowResult` contains `{ value, stopReason, error?, agentsStarted }`; `value` is plain JSON data or `null`. diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index faef18aeb0..12386659bc 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -70,6 +70,17 @@ export interface WorkflowStartRequest { meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown + /** + * Optional engine-wide child-provider override for this run. The workflow + * script cannot observe or replace it; omission uses the engine's configured + * provider. + */ + subagentProvider?: string + /** + * Optional per-run total-child ceiling. Implementations reject values above + * their deployment ceiling before publishing the run. + */ + maxTotalAgents?: number /** The agent on whose behalf the run executes (parent of every child). */ parent: Agent /** Cancels the run when aborted (the tool's `exec.signal`). */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3afaa294a..bcd658d85c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:* version: link:../packages/goal/goal + '@deepseek-ai/dsh-goal-session': + specifier: workspace:* + version: link:../packages/goal/goal-session '@deepseek-ai/dsh-hooks-claude': specifier: workspace:* version: link:../packages/hooks/hooks-claude @@ -203,6 +206,12 @@ 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-ralph': + specifier: workspace:* + version: link:../packages/workflow/tool-ralph '@deepseek-ai/dsh-tool-subagent': specifier: workspace:* version: link:../packages/subagent/tool-subagent @@ -761,6 +770,12 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../ui/app-boot + '@deepseek-ai/dsh-command-goal': + specifier: workspace:^ + version: link:../../goal/command-goal + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -804,6 +819,12 @@ importers: '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../fs/fs-local + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal + '@deepseek-ai/dsh-goal-session': + specifier: workspace:^ + version: link:../../goal/goal-session '@deepseek-ai/dsh-home': specifier: workspace:^ version: link:../../util/home @@ -837,6 +858,9 @@ importers: '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../bash/tool-bash + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:^ + version: link:../../goal/tool-goal '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../skill/tool-skill @@ -931,6 +955,12 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../ui/app-boot + '@deepseek-ai/dsh-command-goal': + specifier: workspace:^ + version: link:../../goal/command-goal + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1139,6 +1169,33 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/goal/command-goal: + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../goal + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + 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/goal/goal: dependencies: schemastery: @@ -1170,6 +1227,73 @@ 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/goal-session: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../goal + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@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@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-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@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: @@ -2286,6 +2410,9 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../bash/bash-local + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../commands '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../fs/fs-local @@ -2359,6 +2486,24 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/ui/commands: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + 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/ui/jsonrpc: dependencies: schemastery: @@ -2469,6 +2614,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2726,6 +2874,58 @@ 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/workflow/tool-ralph: + 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-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + '@deepseek-ai/dsh-subagent-inprocess': + specifier: workspace:^ + version: link:../../subagent/subagent-inprocess + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../../subagent/subagent-spawn + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-workflow': + specifier: workspace:^ + version: link:../workflow + '@deepseek-ai/dsh-workflow-workerthread': + specifier: workspace:^ + version: link:../workflow-workerthread + 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/workflow/tool-workflow: dependencies: schemastery: @@ -2874,6 +3074,12 @@ importers: '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../packages/code-runtime/code-runtime-worker + '@deepseek-ai/dsh-command-goal': + specifier: workspace:^ + version: link:../../packages/goal/command-goal + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../packages/ui/commands '@deepseek-ai/dsh-compact': specifier: workspace:^ version: link:../../packages/compact/compact @@ -2892,6 +3098,12 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../packages/fs/fs-policy + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../packages/goal/goal + '@deepseek-ai/dsh-goal-session': + specifier: workspace:^ + version: link:../../packages/goal/goal-session '@deepseek-ai/dsh-home': specifier: workspace:^ version: link:../../packages/util/home @@ -3006,6 +3218,9 @@ importers: '@deepseek-ai/dsh-tool-fs': specifier: workspace:^ version: link:../../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:^ + version: link:../../packages/goal/tool-goal '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a9405ddd80..5661d88de1 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -18,11 +18,15 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-code-runtime": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", + "@deepseek-ai/dsh-command-goal": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-hooks-claude": "workspace:^", @@ -62,6 +66,7 @@ "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-goal": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 51d7db9115..43cba9b10e 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -77,6 +77,10 @@ export const LINK_MAP: Record = { GoalChanged: 'goal.md', GoalRef: 'goal.md', GoalView: 'goal.md', + CommandDefinition: 'commands.md', + CommandDescriptor: 'commands.md', + CommandResult: 'commands.md', + CommandSurface: 'commands.md', LlmAdapter: 'llm-streaming.md', LlmService: 'llm-streaming.md', StreamChunk: 'llm-streaming.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index cbd7f211cd..ffe336824a 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -161,6 +161,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-ask-user', 'tui', 'acp'], note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.', }, + { + key: 'commands', + pkg: 'commands', + title: 'Human command registry', + mode: 'core', + consumers: ['tui', 'acp'], + note: 'Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model.', + }, { key: 'skills', pkg: 'skill', @@ -279,8 +287,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Subagent provider registry', mode: 'seam', implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'], - consumers: ['tool-subagent'], - note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.', + consumers: ['tool-subagent', 'tool-ralph'], + note: 'Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route.', }, { key: 'tasks', @@ -314,8 +322,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Workflow script engine', mode: 'seam', implementations: ['workflow-workerthread'], - consumers: ['tool-workflow'], - note: 'One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents.', + consumers: ['tool-workflow', 'tool-ralph'], + note: 'One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.', }, ] diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 4840d81dbc..0e7e5aa8e1 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 { BashExecutor } from '@deepseek-ai/dsh-bash' @@ -30,12 +32,14 @@ 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' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' import * as ToolWeb from '@deepseek-ai/dsh-tool-web' import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' +import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') @@ -224,6 +228,35 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). 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-ralph', + dir: 'tool-ralph', + source: 'packages/workflow/tool-ralph/src/index.ts', + requires: ['ctx.tools', 'ctx.workflows', 'ctx.subagents', 'ctx.systemPrompt', 'a calling Agent (exec.agent parents every fresh round)'], + writes: ['tool/call', 'tool/result', 'workflow and child session events during execution'], + async mount(ctx) { + await ctx.plugin(SubagentService) + registerCatalogSubagentProvider(ctx, 'mock') + await ctx.plugin(VmWorkflowEngine, { provider: 'mock' }) + await ctx.plugin(ToolRalph, { subagentProvider: 'mock' }) + }, + note: + 'A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap.', + }, { pkg: '@deepseek-ai/dsh-tool-skill', dir: 'tool-skill', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index eb507d1c35..5cb84266da 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -39,6 +39,13 @@ { "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalChanged", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandInputDescriptor", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandDefinition", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandInvocation", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandResult", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandDescriptor", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "ParsedCommand", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "AssembleContext", "source": "packages/core/system-prompt/src/index.ts" }, { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptSection", "source": "packages/core/system-prompt/src/index.ts" }, { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" }, diff --git a/tsconfig.build.json b/tsconfig.build.json index b1f1f21b0b..17d3cc52e1 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -25,7 +25,10 @@ { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/ui/commands" }, { "path": "./packages/goal/goal" }, + { "path": "./packages/goal/tool-goal" }, + { "path": "./packages/goal/goal-session" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, @@ -94,6 +97,7 @@ { "path": "./packages/workflow/workflow" }, { "path": "./packages/workflow/workflow-workerthread" }, { "path": "./packages/workflow/tool-workflow" }, + { "path": "./packages/workflow/tool-ralph" }, { "path": "./packages/todo/tool-todo" }, { "path": "./packages/guard/repeat-tool-guard" }, { "path": "./packages/cordis/tool-cordis" }, diff --git a/tsconfig.json b/tsconfig.json index ab9fde4948..14baf6dbf3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,7 +38,11 @@ { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/ui/commands" }, { "path": "./packages/goal/goal" }, + { "path": "./packages/goal/tool-goal" }, + { "path": "./packages/goal/goal-session" }, + { "path": "./packages/goal/command-goal" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, @@ -107,6 +111,7 @@ { "path": "./packages/workflow/workflow" }, { "path": "./packages/workflow/workflow-workerthread" }, { "path": "./packages/workflow/tool-workflow" }, + { "path": "./packages/workflow/tool-ralph" }, { "path": "./packages/todo/tool-todo" }, { "path": "./packages/guard/repeat-tool-guard" }, { "path": "./packages/cordis/tool-cordis" }, diff --git a/website/zh-CN/api/harness/commands.md b/website/zh-CN/api/harness/commands.md new file mode 100644 index 0000000000..3b92682dac --- /dev/null +++ b/website/zh-CN/api/harness/commands.md @@ -0,0 +1,91 @@ + + +# ctx.commands + +`CommandService` — provided by `@deepseek-ai/dsh-commands`. + +Human-command registry. Plain-context definitions are global; definitions registered through a command-injected child of an agent context shadow globals for that agent. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L207) + +### ctx.commands.register(definition) + +```ts website-api +/** + * Register a global or calling-agent-scoped command. + * @param definition - discovery metadata and direct UI handler. + * @returns the exact effect disposer that unregisters this definition. + */ +register(definition: CommandDefinition): () => void +``` + +Register a global or calling-agent-scoped command. + +- `definition` — discovery metadata and direct UI handler. + +**Returns** the exact effect disposer that unregisters this definition. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L220) + +### ctx.commands.list(agent) + +```ts website-api +/** + * List the effective immutable command descriptors for one agent. + * @param agent - exact receiving agent and scoped-layer key. + * @returns name-sorted descriptors after scoped shadowing. + */ +list(agent: Agent): readonly CommandDescriptor[] +``` + +List the effective immutable command descriptors for one agent. + +- `agent` — exact receiving agent and scoped-layer key. + +**Returns** name-sorted descriptors after scoped shadowing. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L247) + +### ctx.commands.find(agent, name) + +```ts website-api +/** + * Resolve one effective command definition. + * @param agent - exact receiving agent and scoped-layer key. + * @param name - command name without a slash. + * @returns the scoped shadow or global definition. + */ +find(agent: Agent, name: string): CommandDefinition | undefined +``` + +Resolve one effective command definition. + +- `agent` — exact receiving agent and scoped-layer key. +- `name` — command name without a slash. + +**Returns** the scoped shadow or global definition. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L260) + +### ctx.commands.execute(agent, line, signal) + +```ts website-api +/** + * Parse and execute a known command without sending it to the model. + * @param agent - exact receiving agent. + * @param line - complete slash-command line. + * @param signal - cancellation signal owned by the UI request. + * @returns a detached result, or `undefined` when syntax or name does not resolve. + */ +async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise +``` + +Parse and execute a known command without sending it to the model. + +- `agent` — exact receiving agent. +- `line` — complete slash-command line. +- `signal` — cancellation signal owned by the UI request. + +**Returns** a detached result, or `undefined` when syntax or name does not resolve. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L271) diff --git a/website/zh-CN/api/harness/goals.md b/website/zh-CN/api/harness/goals.md index ee86d7be62..828a5eced3 100644 --- a/website/zh-CN/api/harness/goals.md +++ b/website/zh-CN/api/harness/goals.md @@ -28,6 +28,27 @@ Read the current goal for one exact live agent. [Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L161) +### ctx.goals.disarm(agent) + +```ts website-api +/** + * Remove process-local continuation authority without changing durable goal + * phase or revision. Lifecycle owners use this before unloading a driver; + * a later human-authorized {@link resume} records the new activation edge. + * @param agent - owning live agent. + * @returns a fresh disarmed view, or `undefined` when no goal is current. + */ +disarm(agent: Agent): GoalView | undefined +``` + +Remove process-local continuation authority without changing durable goal phase or revision. Lifecycle owners use this before unloading a driver; a later human-authorized resume records the new activation edge. + +- `agent` — owning live agent. + +**Returns** a fresh disarmed view, or `undefined` when no goal is current. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L175) + ### ctx.goals.create(agent, request) ```ts website-api @@ -48,7 +69,7 @@ Create and arm a goal. A completed goal may be replaced; every other current pha **Returns** the created live view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L175) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L190) ### ctx.goals.edit(agent, ref, request) @@ -71,7 +92,7 @@ Edit objective and/or round cap without changing phase. **Returns** the edited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L200) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L215) ### ctx.goals.pause(agent, ref) @@ -92,7 +113,7 @@ Pause an active goal and disarm automatic continuation. **Returns** the paused view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L221) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L236) ### ctx.goals.resume(agent, ref) @@ -114,7 +135,7 @@ Resume and arm a stopped goal, or rearm an active goal after a session-start edg **Returns** the active view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L232) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L247) ### ctx.goals.complete(agent, ref) @@ -135,7 +156,7 @@ Mark a current non-complete goal complete and disarm it. **Returns** the completed view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L257) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L272) ### ctx.goals.block(agent, ref, reason) @@ -158,7 +179,7 @@ Mark an active goal blocked and disarm it. **Returns** the blocked view with its durable reason. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L275) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L290) ### ctx.goals.clear(agent, ref) @@ -179,4 +200,4 @@ Clear the current goal while retaining a durable tombstone and history. **Returns** the tombstone ref whose revision is one past the cleared snapshot. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L296) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L311)