Merge pull request #405 from deepseek-harness/codex/goal-session

feat(goal): drive same-session goal rounds
This commit is contained in:
Tianyi Cui
2026-07-21 00:36:00 +08:00
committed by GitHub
51 changed files with 2126 additions and 63 deletions
@@ -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
@@ -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
@@ -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% 覆盖率门禁约束。
## 考虑过的替代方案
@@ -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
@@ -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.
@@ -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 结构兼容无法验证运行时顺序。
+1 -1
View File
@@ -118,7 +118,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.
+1
View File
@@ -1547,6 +1547,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/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-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/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))
+32 -11
View File
@@ -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: 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.
@@ -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:318`](../../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:270`](../../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:210`](../../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:220`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts)
### `agent/queued` — emit
@@ -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:232`](../../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:285`](../../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:247`](../../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:194`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts)
### `agent/status` — emit
@@ -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:258`](../../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:295`](../../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:305`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts)
## `agent-loop/*`
+9
View File
@@ -498,6 +498,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.
+4 -4
View File
@@ -389,10 +389,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
+18 -17
View File
@@ -8,30 +8,31 @@ 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:153`](../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:162`](../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:318`](../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:270`](../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:210`](../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:220`](../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:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:232`](../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:285`](../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:247`](../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:194`](../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:171`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`tui`](../packages/ui/tui) |
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:258`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:295`](../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:305`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
| `agent/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`) | [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`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) |
| `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`) | [`invariants`](../packages/support/invariants), [`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`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `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), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`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), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`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/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
+6
View File
@@ -31,6 +31,7 @@ flowchart TD
end
subgraph group_goal["packages/goal"]
pkg_goal["goal"]
pkg_goal_session["goal-session"]
pkg_tool_goal["tool-goal"]
end
subgraph group_bash["packages/bash"]
@@ -264,6 +265,10 @@ flowchart TD
pkg_tools --> pkg_session
pkg_tools --> pkg_system_prompt
pkg_tools --> pkg_user_approval
pkg_goal_session --> pkg_agent
pkg_goal_session --> pkg_goal
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_sandbox
@@ -542,6 +547,7 @@ flowchart TD
| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) |
| [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`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), [`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), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
@@ -0,0 +1,19 @@
# 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'
config:
providers:
- id: deepseek
name: DeepSeek
models:
- id: deepseek-v4-flash
- id: deepseek-v4-pro
+13
View File
@@ -0,0 +1,13 @@
# Add the persisted same-session goal stack to the shipped ACP 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'
- id: goal-session
name: '@deepseek-ai/dsh-goal-session'
@@ -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" }
]
}
@@ -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" }
]
@@ -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_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"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":"<goal_round>\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</goal_round>"}],"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":"<goal_round>\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</goal_round>"}],"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_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"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"}}}
@@ -0,0 +1 @@
{"type":"session","version":0,"id":"goal-session-placeholder","createdAt":0,"cwd":"/tmp/goal-session-placeholder"}
@@ -0,0 +1,10 @@
{"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":"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"}}}}
+114
View File
@@ -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('../goal.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'))
})
})
@@ -10,4 +10,3 @@
- insert:
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
-1
View File
@@ -9,4 +9,3 @@
name: '@deepseek-ai/dsh-goal'
- id: tool-goal
name: '@deepseek-ai/dsh-tool-goal'
@@ -6,4 +6,3 @@
}
]
}
@@ -30,4 +30,3 @@
]
}
]
+2 -1
View File
@@ -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:*",
+4
View File
@@ -82,6 +82,10 @@
"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"]
@@ -258,6 +258,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 */',
@@ -674,6 +678,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: 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',
+1 -1
View File
@@ -56,7 +56,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.
+7 -2
View File
@@ -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)
}
/**
+28 -1
View File
@@ -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)
+2 -2
View File
@@ -44,7 +44,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.
@@ -57,7 +57,7 @@ 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. 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`
+14 -4
View File
@@ -125,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
@@ -179,6 +179,16 @@ declare module 'cordis' {
* @mode emit
*/
'agent/queued'(this: Scoped<Agent>, 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: Agent, reason: string): void
// ---- session lifecycle (emit) ----
/**
+1
View File
@@ -5,6 +5,7 @@ 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 | — |
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.
+71
View File
@@ -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 `<goal_round>` prompt with `GoalMessageSource`. Admission through `agent/prompt-submit` verifies the complete queued record and current goal both before and after downstream prompt hooks; only the accepted `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number.
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 `<goal_round>` 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.
+42
View File
@@ -0,0 +1,42 @@
{
"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"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-goal": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"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-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
+456
View File
@@ -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<void> | undefined
stopping: boolean
readonly flushFailedTurns: Set<number>
}
/** 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<Agent, DriverState>()
/** 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<void> {
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<void>
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<PromptDecision> => {
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<void>[] = []
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')
}
+53
View File
@@ -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}`,
}
}
}
+26
View File
@@ -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: '<goal_round>\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'
+ '</goal_round>',
}]
}
@@ -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<StreamChunk> {
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<void>((_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<ReturnType<Context['plugin']>>
}
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<Harness> {
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<GoalView | undefined> {
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<void> {
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(
/<goal_round>\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</goal_round> 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</goal_round> 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('<goal_round>')
expect(requestText(test.adapter.requests[1]!)).toContain('<goal_round>')
})
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('<goal_round>')
expect(requestText(test.adapter.requests[1]!)).toContain('<goal_round>')
})
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<void>((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<void>((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<void> | 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()
})
})
+30
View File
@@ -0,0 +1,30 @@
{
"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"
}
]
}
+2 -2
View File
@@ -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.
## Extension points
+15
View File
@@ -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.
+14
View File
@@ -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)
@@ -41,12 +41,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'
@@ -331,6 +334,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')
@@ -334,6 +334,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 +458,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/],
@@ -28,6 +28,7 @@ function adapt<K extends ScopedEventName>(
}
const scopedSubjectResolvers = Object.freeze({
'agent/cancel-requested': adapt<'agent/cancel-requested'>(args => args[0]),
'agent/created': adapt<'agent/created'>(args => args[0]),
'agent/disposed': adapt<'agent/disposed'>(args => args[0]),
'agent/error': adapt<'agent/error'>(args => args[0]),
+33
View File
@@ -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
@@ -1092,6 +1095,36 @@ 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-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:
+1
View File
@@ -27,6 +27,7 @@
{ "path": "./packages/core/agent" },
{ "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" },
+1
View File
@@ -40,6 +40,7 @@
{ "path": "./packages/core/agent" },
{ "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" },
+28 -7
View File
@@ -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)