Merge pull request #402 from deepseek-harness/codex/goal-domain

feat(goal): add persisted same-session goal domain
This commit is contained in:
Tianyi Cui
2026-07-21 00:25:32 +08:00
committed by GitHub
43 changed files with 2999 additions and 4 deletions
@@ -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-persisted-same-session-goal-domain.md: b0149016ab1b2a21c6d21798bf8b2117472a0f6c
2026-07-19-persisted-same-session-goal-domain.zh.md: 33f44136da3f0045d9f4baad5154797a6e746bcc
@@ -0,0 +1,63 @@
# Agent Note: Persisted same-session goal domain
Status: implemented
English | [中文](2026-07-19-persisted-same-session-goal-domain.zh.md)
## Problem
A long-running objective outlives one prompt, turn, or model request. Treating that objective as an in-memory loop variable loses it on process restart, while putting it only in UI state makes model behavior impossible to reconstruct. Treating every session turn as progress also charges unrelated human messages against an automatic-work budget.
Durable lifecycle and permission to continue are different facts. A session may retain an active objective after restart or fork, but silently starting work when a user opens that session is surprising. The domain needs replayable state without persisted auto-execution authority, and it must remain a plugin on the public agent/session seams rather than a special case in the concrete loop.
## Decision
`@deepseek-ai/dsh-goal` in `packages/goal/goal/` owns one current same-session goal through `ctx.goals`. A goal has a branded id, objective, durable phase, compare-and-set revision, and `maxGoalRounds`. `defaultMaxGoalRounds` is a validated deployment setting with default `256`; `create()` materializes it internally before mutation rather than exposing resolution as another service verb.
The durable phases are `active`, `paused`, `blocked`, and `complete`. A blocked snapshot includes a policy-owned lower-kebab-case code and a normalized free-form message, so usage limits, round caps, execution failures, and human-input dependencies share one lifecycle state without losing their cause. A separate live activation is `armed` or `disarmed`. Creation and explicit resume arm activation; pause, completion, blocking, and clear disarm it. Edits preserve activation and any blocker reason; resume and completion clear that reason. Activation is never part of the persisted snapshot.
### Durable record and replay
Every non-clear mutation uses `Agent.inject()` to append a model-visible `context/message` containing a versioned full snapshot; the session projects that content verbatim. Clear appends a revisioned tombstone. The context source is `{ kind: 'goal', goalId, revision, round: 0 }`; metadata and rendered `<goal_state>...</goal_state>` content must agree exactly. This descriptive delimiter follows the repository's existing `<workspace_context>` convention and [Anthropic's published guidance to structure mixed prompt content with consistent descriptive XML tags](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags). That is public model-experience prior art, not evidence about any provider's proprietary training corpus. The session log is the only durable source of truth, so persistence and fork inherit goal records without another database or header field.
The replay fold validates JSON shape, source attribution, rendered content, fresh ids, revision continuity, lifecycle transitions, counters, and monotonic per-goal timestamps. Goal rounds are positive sequential `user/message` source numbers for the current active revision and cannot exceed `maxGoalRounds`; ordinary session turns do not affect the counter. A malformed current-format record fails replay rather than being ignored or repaired.
When `Agent.inject()` defers a mutation inside an active tool batch, the service overlays the accepted payload in process memory so a later mutation can use its new revision. Reconciliation removes only an exact matching payload when the FIFO append becomes visible; reentrant append observers project each mutation exactly once. Incremental replay advances its cursor after each valid event and remains positioned at the first corrupt event, so later reads report the same durable fault. The durable log remains authoritative after restart.
### Lifecycle and live activation
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.
### Service boundary
The service accepts only the exact live `Agent` object registered under its id. Successful mutation injection emits the scoped `goal/changed` event with contained listener failures. Policy consumers use this service plus the public `Agent` interface and `agent/*` events; the goal domain does not import or modify `dsh-agent-loop`.
## 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.
## Alternatives considered
- **Store goals in a separate database or session header** — rejected because the session log already supplies ordering, persistence, fork prefixes, and reconstructability; a second store introduces atomicity and lineage questions.
- **Use hidden log-only events** — rejected because durable state that changes future model behavior must be model-visible and reconstructable under the repository's logging invariant.
- **Persist activation and restart automatically** — rejected because opening or resuming a session must wait for human input; durable phase records status, not fresh authority to spend resources.
- **Count all session turns as goal rounds** — rejected because one session can contain human clarification, inspection, and unrelated work; only goal-attributed continuation turns consume this budget.
- **Add goal state or a generic loop abstraction to `dsh-agent-loop`** — rejected because state and continuation policy can compose through existing plugins, `Agent` verbs, and events without privileging the shipped loop implementation.
## Consequences
- Goal history survives persistence, resume, compaction of unrelated nodes, and session fork as ordinary session data.
- Resume and fork expose the same durable phase while remaining operationally inert until an explicit resume mutation arms activation.
- Full snapshots simplify inspection and strict replay but repeat the objective and state fields in model history until compaction shadows them.
- Revision and lifecycle validation reject tampered, partially written, or producer-inconsistent goal records early.
- Round caps bound continuation count only; policy consumers map round, token, currency, time, and provider limits to blocked reasons when they stop work.
## Known limitations and deferred work
- This domain records state but does not schedule goal rounds, cancel active turns, or classify abnormal stops.
- The actor that records `complete` or `blocked` is authoritative; an independent evaluator or completion certificate is deferred to a policy consumer.
- There is one current goal per session; parallel objective graphs and cross-session goal storage are absent.
- Plugins share one trusted process boundary. Direct session writers can counterfeit goal records; strict replay detects inconsistency and fails goal access at the offending record, but does not isolate plugins or repair the log.
- `GOAL_CHANGE_VERSION` has no pre-release compatibility promise or migration path.
@@ -0,0 +1,63 @@
# Agent Note: 持久的同会话目标领域
Status: implemented
[English](2026-07-19-persisted-same-session-goal-domain.md) | 中文
## 问题
长时间运行的目标会跨越单个提示词、轮次或模型请求。若把该目标视为内存中的循环变量,进程重启时就会丢失;若只存放在 UI 状态中,又无法重建模型行为。若把会话中的每个轮次都视为目标进度,与自动工作无关的人类消息也会消耗预算。
持久生命周期与继续执行的权限是两个不同事实。会话在重启或 fork(派生)后可以保留活跃目标,但用户打开会话时静默启动工作并不符合直觉。该领域需要可回放的状态,却不能持久化自动执行权限;它还必须作为公共 agent(智能体)与会话接缝上的插件存在,而不是具体循环中的特例。
## 决策
位于 `packages/goal/goal/``@deepseek-ai/dsh-goal` 通过 `ctx.goals` 管理一个当前的同会话目标。目标包含品牌化 id、目标描述、持久阶段、比较并交换修订号和 `maxGoalRounds``defaultMaxGoalRounds` 是经过校验的部署配置,默认值为 `256``create()` 在变更前于内部将其解析为完整值,而不会把解析过程暴露为额外的服务动词。
持久阶段包括 `active``paused``blocked``complete`。阻塞快照包含由策略提供的 kebab-case 小写代码和规范化自由文本消息,因此用量限制、回合上限、执行失败和等待人工输入可以共享一个生命周期状态而不丢失原因。独立的实时激活态为 `armed``disarmed`。创建与显式恢复会激活目标;暂停、完成、阻塞和清除都会解除激活。编辑保留激活态及阻塞原因;恢复和完成会清除该原因。持久快照绝不包含激活态。
### 持久记录与回放
每次非清除变更都通过 `Agent.inject()` 追加一条模型可见的 `context/message`,其中包含带版本的完整快照;会话会将其内容原样投射给模型。清除操作追加带修订号的墓碑。上下文来源为 `{ kind: 'goal', goalId, revision, round: 0 }`;元数据必须与渲染后的 `<goal_state>...</goal_state>` 内容完全一致。这个描述性分隔符沿用了仓库已有的 `<workspace_context>` 约定,也符合 [Anthropic 关于用一致且描述明确的 XML 标签组织混合提示词内容的公开指南](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags)。这是公开的模型体验先例,并非对任何提供方专有训练语料的推断。会话日志是唯一的持久事实来源,因此持久化和 fork 会继承目标记录,而无需另设数据库或头字段。
回放折叠会校验 JSON 形状、来源归属、渲染内容、新 id、修订连续性、生命周期转换、计数器以及单个目标内单调递增的时间戳。目标回合是当前活跃修订上带正数且连续编号的 `user/message` 来源,且不能超过 `maxGoalRounds`;普通会话轮次不会影响该计数器。当前格式的畸形记录会使回放失败,而不会被忽略或修复。
`Agent.inject()` 在活跃工具批次中延迟变更时,服务会在进程内叠加已接受的载荷,使后续变更可以使用新的修订号。FIFO 追加可见后,协调过程只移除完全匹配的载荷;重入的追加观察器对每次变更只投影一次。增量回放会在每个有效事件后推进游标,并停留在首个损坏事件处,因此后续读取会报告同一个持久故障。重启后仍以持久日志为准。
### 生命周期与实时激活态
最多只有一个当前目标。创建要求不存在未完成的当前目标,并始终生成该会话此前未使用过、修订号为一的 id;已完成目标可以被替换。其他每次变更都携带预期的 `GoalRef`,陈旧的 id 或修订号会被拒绝。仅当回合上限仍有余量时,暂停或阻塞阶段以及已解除激活的活跃目标才能恢复。领域层校验阻塞原因的形状,但会把原因代码和是否阻塞的决策留给策略消费者。
从任何种子构建的缓存都以未激活状态开始,每次 `agent/session-start` 边沿也会再次解除激活。因此,恢复和 fork 会保留持久目标与历史,但绝不会自行启动工作。后续人类提示词可由模型解释,其策略表面可以显式调用恢复操作并激活目标。
### 服务边界
服务只接受在对应 id 下注册的同一个实时 `Agent` 对象。成功注入变更后,它会发出带作用域的 `goal/changed` 事件,并隔离监听器失败。策略消费者通过本服务、公共 `Agent` 接口和 `agent/*` 事件工作;目标领域既不导入也不修改 `dsh-agent-loop`
## 测试
单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、阻塞原因校验与保留、恢复时的上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动时解除激活与活跃目标重新激活、FIFO 延迟变更协调、重入追加观察、注入拒绝回滚、损坏事件的稳定回放、服务与监听器销毁、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。
## 考虑过的替代方案
- **把目标存入独立数据库或会话头**——不予采纳,因为会话日志已经提供顺序、持久化、fork 前缀与可重建性;第二份存储会引入原子性和谱系问题。
- **使用模型不可见的纯日志事件**——不予采纳,因为会改变后续模型行为的持久状态必须满足仓库日志不变量,保持模型可见且可重建。
- **持久化激活态并自动重启**——不予采纳,因为打开或恢复会话时必须等待人类输入;持久阶段记录状态,而不是再次消耗资源的授权。
- **把所有会话轮次都计为目标回合**——不予采纳,因为同一会话可以包含人类澄清、检查和无关工作;只有归属于目标的继续执行轮次才消耗该预算。
- **向 `dsh-agent-loop` 添加目标状态或通用循环抽象**——不予采纳,因为状态与继续执行策略可以通过现有插件、`Agent` 动词和事件组合,而无需赋予默认循环实现特权。
## 后果
- 目标历史作为普通会话数据,在持久化、恢复、无关节点压缩和会话 fork 后继续保留。
- 恢复与 fork 会暴露同一持久阶段,但在显式恢复变更激活目标前不会执行任何操作。
- 完整快照便于检查和严格回放,但在压缩隐藏它们之前,会在模型历史中重复目标描述与状态字段。
- 修订号与生命周期校验会尽早拒绝遭篡改、部分写入或生产者不一致的目标记录。
- 回合上限只约束继续执行次数;当回合、token、费用、时间或提供方限制停止工作时,策略消费者会把它们映射为不同的阻塞原因。
## 已知限制与延期工作
- 本领域记录状态,但不调度目标回合、不取消活跃轮次,也不分类异常停止。
- 记录 `complete``blocked` 的参与者具有最终权威;独立评估器或完成证书延期到策略消费者中实现。
- 每个会话只有一个当前目标;不存在并行目标图和跨会话目标存储。
- 插件共享同一个受信任的进程边界。直接写入会话的插件可以伪造目标记录;严格回放会检测不一致并在违规记录处使目标访问失败,但不会隔离插件或修复日志。
- `GOAL_CHANGE_VERSION` 在首次发布前不承诺兼容性,也不提供迁移路径。
+5 -3
View File
@@ -1,10 +1,10 @@
# DeepSeek Harness Architecture
The **DeepSeek Harness SDK** builds agent harnesses on Cordis. The principle is simple: **everything is a plugin**. The shipped loop is one plugin, not a privileged kernel.
The **DeepSeek Harness SDK** builds on Cordis: **everything is a plugin**, including the shipped loop.
## Overview
A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx.llm`, `ctx.tools`, `ctx.sessions`), typed events (`agent/request`, `tools/pre-execute`, `session/event`), and disposable prompt, tool, provider, adapter, and listener registrations.
Harnesses are [Cordis](cordis-primer.md) contexts. Packages contribute services (`ctx.llm`, `ctx.tools`, `ctx.sessions`), typed events (`agent/request`, `tools/pre-execute`, `session/event`), and disposable prompts, tools, providers, adapters, and listeners.
`packages/core/` groups the default agent flow; surrounding capabilities are equally first-class Cordis plugins.
@@ -36,6 +36,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx
| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers |
| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry + generic `task_*` control tools |
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration |
| `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals |
| `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred logical-corpus exact reads and relationship traces |
@@ -109,7 +110,7 @@ forever:
Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)).
Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles, then follows recorded results. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering before signal closure. Leftovers become queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, stays authoritative through turn close and flush, and discards later steering but preserves queued prompts.
Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles after results. Steering drains; before signal closure, `agent/post-step` observes durable output, results, context, and steering. Leftovers queue. Terminal `agent/turn-stop` runs after continuation and steering folding, remains authoritative through close/flush, and discards later steering while preserving queued prompts.
Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)).
@@ -175,6 +176,7 @@ New behavior should attach to a documented extension point; changing the shipped
| Add a session-stable request prefix outside history | compose it on `agent/session-prefix`, once per loop instance; logged on the request header |
| Add UI or editor integration | drive `ctx.agents` and render from `session/event` |
| Add durable session state | add a `SessionEventMap` member and render/replay from the log |
| Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` |
| Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` |
| Scope a tool, prompt section, or listener to ONE agent | register it through that agent's `agent.ctx` (see Agent Scope) |
+4
View File
@@ -55,6 +55,8 @@ flowchart LR
pkg_tui_demo["tui-demo"]
svc_agentLoop["ctx.agentLoop<br/>Concrete loop driver"]
pkg_agent_spine_demo["agent-spine-demo"]
pkg_goal["goal"]
svc_goals["ctx.goals<br/>Same-session goal domain"]
pkg_bash["bash"]
svc_bash["ctx.bash<br/>Bash executor seam"]
pkg_bash_local["bash-local"]
@@ -117,6 +119,7 @@ flowchart LR
pkg_fs --> svc_fs
pkg_fs_local --> svc_fs
pkg_fs_sandbox --> svc_fs
pkg_goal --> svc_goals
pkg_llm --> svc_llm
pkg_llm_deepseek --> svc_llm
pkg_llm_pi_ai --> svc_llm
@@ -230,6 +233,7 @@ flowchart LR
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. |
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. |
| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. |
| `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. |
+14
View File
@@ -359,6 +359,20 @@ Depends on: [`LocalConfig`](#deepseek-aidsh-fs-local)
Source: [`packages/fs/fs-sandbox/src/index.ts:49`](../packages/fs/fs-sandbox/src/index.ts)
## `@deepseek-ai/dsh-goal`
Requires: `agents`
```ts config-catalog
/** Deployment defaults for goal creation. */
export interface Config {
/** Total rounds used when a create request omits its own cap. */
defaultMaxGoalRounds?: number
}
```
Source: [`packages/goal/goal/src/index.ts:56`](../packages/goal/goal/src/index.ts)
## `@deepseek-ai/dsh-hooks-claude`
Requires: `bash`
+23
View File
@@ -451,6 +451,29 @@ Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../c
Source: [`packages/fs/fs/src/index.ts:54`](../../packages/fs/fs/src/index.ts)
## `goal/*`
### `goal/changed` — emit
Goal mutation accepted by one live agent. The matching context event is already appended or queued in that agent's active tool-batch FIFO. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
```ts cordis-catalog
/**
* Goal mutation accepted by one live agent. The matching context event is
* already appended or queued in that agent's active tool-batch FIFO.
* Listener failures are contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @param agent - agent whose session owns the goal.
* @param change - fresh current projection or clear tombstone.
* @mode emit
*/
'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, agent: Agent, change: GoalChanged): void
```
Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/goal/goal/src/types.ts:167`](../../packages/goal/goal/src/types.ts)
## `llm/*`
### `llm/stream` — waterfall
+78
View File
@@ -485,6 +485,84 @@ Types: [FsDirEntry](../core-data-structures/filesystem.md) · [FsEditOutcome](..
Source: [`packages/fs/fs/src/index.ts:81`](../../packages/fs/fs/src/index.ts)
## `ctx.goals` — `GoalService`
Goal service (`ctx.goals`) backed exclusively by the owning session log.
```ts cordis-catalog
/**
* Read the current goal for one exact live agent.
* @param agent - owning live agent.
* @returns a fresh view or `undefined` when no goal is current.
* @throws {@link GoalError} when the agent is not the registry's live instance.
*/
get(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.
* @param agent - owning live agent.
* @param request - objective and optional round cap.
* @returns the created live view.
*/
create(agent: Agent, request: CreateGoalRequest): GoalView
/**
* Edit objective and/or round cap without changing phase.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @param request - at least one replacement field.
* @returns the edited view.
*/
edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView
/**
* Pause an active goal and disarm automatic continuation.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the paused view.
*/
pause(agent: Agent, ref: GoalRef): GoalView
/**
* Resume and arm a stopped goal, or rearm an active goal after a
* session-start edge, while its round budget still has capacity.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the active view.
*/
resume(agent: Agent, ref: GoalRef): GoalView
/**
* Mark a current non-complete goal complete and disarm it.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the completed view.
*/
complete(agent: Agent, ref: GoalRef): GoalView
/**
* Mark an active goal blocked and disarm it.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @param reason - policy-owned stable code and human-readable explanation.
* @returns the blocked view with its durable reason.
*/
block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView
/**
* Clear the current goal while retaining a durable tombstone and history.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the tombstone ref whose revision is one past the cleared snapshot.
*/
clear(agent: Agent, ref: GoalRef): GoalRef
```
Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md)
Source: [`packages/goal/goal/src/index.ts:135`](../../packages/goal/goal/src/index.ts)
## `ctx.llm` — `LlmService`
The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.
+1
View File
@@ -18,6 +18,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam |
| [token-meter.md](token-meter.md) | immutable scalar and positional replay measurements with consumed-log revisions |
| [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context |
| [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution |
| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant |
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
| [session-query.md](session-query.md) | logical records, bounded exact-event reads, and relationship traces |
+143
View File
@@ -0,0 +1,143 @@
# Same-session goals
Types shared by the event-sourced goal domain and its policy consumers. The [goal-domain Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the persistence and activation decisions; this page records the literal shapes from [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts).
## Identity and lifecycle
`GoalId` is a [branded id](core.md#branded-ids). A caller mutates one exact revision through `GoalRef`; every accepted durable mutation increments the revision.
```ts type-equiv
/** Compare-and-set identity for one exact goal revision. */
interface GoalRef {
/** Stable goal identity. */
readonly id: GoalId
/** Positive revision; every durable mutation increments it. */
readonly revision: number
}
```
The durable phase answers what happened to the objective. Process-local activation separately answers whether a continuation consumer may start another round.
```ts type-equiv
/** Durable continuation phase. Activation is process-local and separate. */
type GoalPhase =
| 'active'
| 'paused'
| 'blocked'
| 'complete'
```
Blocking is the single durable stopped-by-a-problem state. Its policy-owned reason carries a stable lower-kebab-case code for routing and a free-form explanation for humans and models.
```ts type-equiv
/** Machine-routable and human-readable explanation for a blocked goal. */
interface GoalBlockReason {
/** Stable lower-kebab-case classification chosen by the blocking policy. */
readonly code: string
/** Non-empty explanation shown to humans and models. */
readonly message: string
}
```
```ts type-equiv
/** Full durable state written by every non-clear goal mutation. */
interface GoalSnapshot extends GoalRef {
/** Human-requested completion objective. */
readonly objective: string
/** Durable lifecycle phase. */
readonly phase: GoalPhase
/** Present exactly while `phase` is `blocked`. */
readonly blockedReason?: GoalBlockReason
/** Total admitted goal-round cap. */
readonly maxGoalRounds: number
}
```
```ts type-equiv
/** Current goal projection, including values derived from the session log. */
interface GoalView extends GoalSnapshot {
/** Highest admitted round number for this goal. */
readonly roundsStarted: number
/** Epoch milliseconds of the create mutation. */
readonly createdAt: number
/** Epoch milliseconds of the latest mutation. */
readonly updatedAt: number
/** Process-local continuation eligibility; never persisted. */
readonly activation: GoalActivation
}
```
## Durable changes
Every mutation is a `context/message` whose metadata is either a complete snapshot or a clear tombstone. The version, metadata, goal source, and verbatim rendered content form one replay invariant.
```ts type-equiv
/** Full-snapshot goal mutation retained in a model-visible context event. */
interface GoalSnapshotChangeMeta {
readonly kind: 'goal/change'
readonly version: 1
readonly operation: Exclude<GoalOperation, 'clear'>
readonly goal: GoalSnapshot
readonly roundsStarted: number
readonly createdAt: number
readonly updatedAt: number
}
```
```ts type-equiv
/** Tombstone retained when the current goal is cleared. */
interface GoalClearChangeMeta {
readonly kind: 'goal/change'
readonly version: 1
readonly operation: 'clear'
readonly cleared: GoalRef
readonly clearedAt: number
}
```
Goal state changes use round `0`. A continuation consumer attributes each admitted user-message turn with a positive, sequential round number and the current revision; replay rejects gaps, stale revisions, stopped phases, and cap overflow.
```ts type-equiv
/** Message attribution for durable goal state and continuation rounds. */
interface GoalMessageSource {
readonly kind: 'goal'
readonly goalId: GoalId
readonly revision: number
/** Zero for state changes; positive for admitted continuation rounds. */
readonly round: number
}
```
## Requests and notifications
Creation separates caller omission from the deployment choice, which `create()` resolves internally. An edit is a partial replacement whose runtime validator requires at least one field. Every mutation notification carries the accepted operation and exact revision; clear omits `goal`.
```ts type-equiv
/** Input whose omitted round cap is resolved by the service configuration. */
interface CreateGoalRequest {
readonly objective: string
readonly maxGoalRounds?: number
}
```
```ts type-equiv
/** Fields changed by an edit; at least one must be present. */
interface EditGoalRequest {
readonly objective?: string
readonly maxGoalRounds?: number
}
```
```ts type-equiv
/** Live notification after one goal mutation has been accepted for logging. */
interface GoalChanged {
readonly operation: GoalOperation
readonly ref: GoalRef
/** Absent for a clear tombstone. */
readonly goal?: GoalView
}
```
## Service behavior
[`GoalService`](../../packages/goal/goal/src/index.ts) resolves creation defaults, folds strict replay, enforces exact-live-agent identity and compare-and-set mutations, overlays deferred injections, and emits contained `goal/changed` notifications. The package [README](../../packages/goal/goal/README.md) owns the callable and model-visible contract.
+2 -1
View File
@@ -18,7 +18,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:229`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:244`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:168`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`tui`](../packages/ui/tui) |
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
@@ -27,6 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `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`) | - |
| `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) |
+12
View File
@@ -15,3 +15,15 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i
- **restriction / scope-local registration** — a restriction (`tools.restrict`) filters the GLOBAL tool surface for one scope (compose by intersection); scope-local registrations are merged after that filter. A filtered-away global tool is absent from the prompt AND refuses execution, indistinguishably from a nonexistent one.
- **setup window** — the creation slot where a creator composes an agent's scoped world (`CreateAgentOptions.setup`): after the scope and agent object exist but before the agent or session is published, `agent/session-start` fires, or the first prompt is assembled. Setup registers; it never drives the agent.
- **lineage** — parent/child facts carried as data (`parentSession`, durable `delegationDepth`, runtime `subagentDepth`); never affects visibility. <a id="lineage"></a>
## goal
- **goal** — one durable completion objective attached to an existing session, with a revisioned `active` / `paused` / `blocked` / `complete` phase and a goal-round cap; `blocked` retains a policy code and explanation. A goal is state, not a scheduler or a separate conversation; the session log remains its source of truth.
- **goal round** — one continuation cycle admitted for the current goal. The same-session driver materializes a goal round as one goal-sourced [turn](#turn), which can contain multiple steps; unrelated human turns in the same session do not consume the goal-round cap. <a id="goal-round"></a>
- **goal activation** — process-local permission for a continuation consumer to admit another goal round. Activation is either `armed` or `disarmed`; it is deliberately absent from durable replay, so resume and fork require a later explicit resume mutation before automatic work.
## loop hierarchy
- **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. <a id="turn"></a>
- **step** — one model request plus the tool executions caused by its response; a turn contains one or more steps. <a id="step"></a>
- **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round). Round counters belong to that policy and do not count every turn in a session. <a id="round"></a>
+9
View File
@@ -29,6 +29,9 @@ flowchart TD
pkg_system_prompt["system-prompt"]
pkg_tools["tools"]
end
subgraph group_goal["packages/goal"]
pkg_goal["goal"]
end
subgraph group_bash["packages/bash"]
pkg_bash["bash"]
pkg_bash_local["bash-local"]
@@ -204,6 +207,11 @@ flowchart TD
pkg_llm_retry --> pkg_llm
pkg_llm_retry --> pkg_session
pkg_llm_retry --> pkg_timeout
pkg_goal --> pkg_agent
pkg_goal --> pkg_brand
pkg_goal --> pkg_llm
pkg_goal --> pkg_scope
pkg_goal --> pkg_session
pkg_bash_local --> pkg_bash
pkg_bash_local --> pkg_timeout
pkg_fs_local --> pkg_fs
@@ -509,6 +517,7 @@ flowchart TD
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) |
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) |
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) |
@@ -0,0 +1,24 @@
# Test-only composition: create one goal through a Loader-mounted step consumer.
- id: cli-mock-llm
name: '../cli-mock-llm.ts'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
- id: goal
name: '@deepseek-ai/dsh-goal'
config:
defaultMaxGoalRounds: 11
- id: seed-goal
name: './seed-goal.ts'
- id: cli-agent
name: '@deepseek-ai/dsh-cli-demo'
config:
provider: cli-mock
model: cli-mock
persona: 'Test the persisted goal domain.'
persistenceRoot: './.sessions'
persistenceCompression: none
workspaceContext: false
@@ -0,0 +1,17 @@
/** Test-only Loader plugin that creates a goal at the first real step edge. */
import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-goal'
export const name = 'seed-goal'
export const inject = ['goals']
export function apply(ctx: Context): void {
ctx.on('agent/pre-step', (agent) => {
if (ctx.goals.get(agent) !== undefined) return
ctx.goals.create(agent, {
objective: 'Prove the composed goal survives in the session log',
maxGoalRounds: 7,
})
})
}
+1
View File
@@ -18,6 +18,7 @@
"@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-hooks-claude": "workspace:*",
"@deepseek-ai/dsh-hooks-codex": "workspace:*",
+5
View File
@@ -10,6 +10,7 @@
"examples": {
"entry": [
"headless-agent/tests/fixtures/cli-mock-llm.ts",
"headless-agent/tests/fixtures/goal-domain/seed-goal.ts",
"headless-agent/tests/fixtures/time-context-driver.ts",
"headless-agent/tests/fixtures/time-context-mock-llm.ts",
"tui-agent/tests/fixtures/tui-scripted-llm.ts",
@@ -77,6 +78,10 @@
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
},
"packages/goal/goal": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
},
"packages/code-runtime/code-runtime-worker": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
+1
View File
@@ -9,6 +9,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| Group | Role | Release expectation |
|---|---|---|
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
@@ -250,6 +250,44 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'goals',
summary: 'Goal service (`ctx.goals`) backed exclusively by the owning session log.',
methods: [
{
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: '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 */',
},
{
signature: 'edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView',
jsDoc: '/**\n * Edit objective and/or round cap without changing phase.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param request - at least one replacement field.\n * @returns the edited view.\n */',
},
{
signature: 'pause(agent: Agent, ref: GoalRef): GoalView',
jsDoc: '/**\n * Pause an active goal and disarm automatic continuation.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the paused view.\n */',
},
{
signature: 'resume(agent: Agent, ref: GoalRef): GoalView',
jsDoc: '/**\n * Resume and arm a stopped goal, or rearm an active goal after a\n * session-start edge, while its round budget still has capacity.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the active view.\n */',
},
{
signature: 'complete(agent: Agent, ref: GoalRef): GoalView',
jsDoc: '/**\n * Mark a current non-complete goal complete and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the completed view.\n */',
},
{
signature: 'block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView',
jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param reason - policy-owned stable code and human-readable explanation.\n * @returns the blocked view with its durable reason.\n */',
},
{
signature: 'clear(agent: Agent, ref: GoalRef): GoalRef',
jsDoc: '/**\n * Clear the current goal while retaining a durable tombstone and history.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the tombstone ref whose revision is one past the cleared snapshot.\n */',
},
],
},
{
key: 'llm',
summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.',
@@ -769,6 +807,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * Single-slot decision for the next {@link FileSystem.writeText}. Calling\n * `next()` yields the bare provider\'s unconditional write; the first listener\n * that returns an intent owns the decision rather than composing with peers.\n * @param target - the resolved target about to be written.\n * @param actor - the opaque tool-execution context the decider keys off.\n * @mode waterfall\n */',
summary: 'Single-slot decision for the next FileSystem.writeText.',
},
{
name: 'goal/changed',
mode: 'emit',
signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped<Agent>, agent: Agent, change: GoalChanged): void',
jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching context event is\n * already appended or queued in that agent\'s active tool-batch FIFO.\n * Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - agent whose session owns the goal.\n * @param change - fresh current projection or clear tombstone.\n * @mode emit\n */',
summary: 'Goal mutation accepted by one live agent.',
},
{
name: 'llm/stream',
mode: 'waterfall',
@@ -1095,6 +1140,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'CreateAgentOptions',
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
},
{
name: 'CreateGoalRequest',
declaration: 'export interface CreateGoalRequest {\n readonly objective: string;\n readonly maxGoalRounds?: number;\n}',
},
{
name: 'CreateSessionOptions',
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}',
@@ -1115,6 +1164,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'DshEnvironmentKey',
declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;',
},
{
name: 'EditGoalRequest',
declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}',
},
{
name: 'EpochHeader',
declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n}',
@@ -1187,6 +1240,34 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'GenericResultView',
declaration: 'export interface GenericResultView {\n card: \'generic\';\n title?: string;\n content?: ContentBlock[];\n}',
},
{
name: 'GoalActivation',
declaration: 'export type GoalActivation = \'armed\' | \'disarmed\';',
},
{
name: 'GoalBlockReason',
declaration: 'export interface GoalBlockReason {\n readonly code: string;\n readonly message: string;\n}',
},
{
name: 'GoalId',
declaration: 'export type GoalId = Branded<\'GoalId\'>;',
},
{
name: 'GoalPhase',
declaration: 'export type GoalPhase = \'active\' | \'paused\' | \'blocked\' | \'complete\';',
},
{
name: 'GoalRef',
declaration: 'export interface GoalRef {\n readonly id: GoalId;\n readonly revision: number;\n}',
},
{
name: 'GoalSnapshot',
declaration: 'export interface GoalSnapshot extends GoalRef {\n readonly objective: string;\n readonly phase: GoalPhase;\n readonly blockedReason?: GoalBlockReason;\n readonly maxGoalRounds: number;\n}',
},
{
name: 'GoalView',
declaration: 'export interface GoalView extends GoalSnapshot {\n readonly roundsStarted: number;\n readonly createdAt: number;\n readonly updatedAt: number;\n readonly activation: GoalActivation;\n}',
},
{
name: 'HookContext',
declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n}',
+9
View File
@@ -0,0 +1,9 @@
# goal/ — persisted same-session goals
The goal family owns durable objective state independently of the model-facing tools and continuation policy that consume it.
| Package | Role | ctx key |
|---|---|---|
| `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` |
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.
+54
View File
@@ -0,0 +1,54 @@
# @deepseek-ai/dsh-goal
Event-sourced same-session goal state. The service retains one current completion objective in an agent's existing session while keeping permission to continue as process-local activation. The [goal-domain Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the design rationale; the [goal type catalog](../../../docs/core-data-structures/goal.md) records the literal data shapes.
## Config
```yaml
- id: goal
name: '@deepseek-ai/dsh-goal'
config:
defaultMaxGoalRounds: 256
```
`defaultMaxGoalRounds` must be a positive safe integer. `create()` materializes this deployment default internally before committing a goal; a request-level value overrides it.
## 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.
At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation.
Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The `context/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
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.
## Extension points
Policy plugins call the service verbs and react to the scoped `goal/changed` event. A continuation consumer admits rounds as `user/message` events with `GoalMessageSource`; ordinary human turns never increment `roundsStarted`. Consumers use the `Agent` interface and events rather than importing `dsh-agent-loop`.
## Model Experience
### Goal-state mutation
#### What the model sees
Each mutation is one raw user-role context block. A snapshot is rendered as `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. The descriptive XML delimiter follows this repository's existing `<workspace_context>` convention and [Anthropic's published XML-tag prompting guidance](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags); it is public model-experience prior art, not a claim about any provider's proprietary training corpus.
#### Token effect
Every retained mutation adds one full snapshot to derived history until compaction shadows it. Full snapshots make each record independently inspectable but repeat the objective and lifecycle fields.
#### KV Cache effect
Append-only within an epoch: each mutation follows the reusable request prefix and preceding history. Compaction may replace the derived-history suffix and move the reusable boundary.
## Known Limitations and Deferred Work
- **State, not scheduling** — this package does not decide when an armed goal continues, retry abnormal failures, or cancel an active turn; those policies belong to agent-seam consumers.
- **Round-count budget only** — `maxGoalRounds` does not meter tokens, currency, wall time, or provider quotas.
- **No independent evaluator** — the caller that records completion or blocking is authoritative; evaluator-backed certification is deferred to a separate policy layer.
- **One current goal** — parallel objectives and a separate goal database are intentionally absent; history remains available in the session log after replacement or clear.
- **Trusted in-process producers** — a plugin with direct `Session` access can append counterfeit goal metadata. Strict replay detects malformed or inconsistent records and leaves goal access failed at that record until the log is repaired; this is integrity detection, not plugin isolation.
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@deepseek-ai/dsh-goal",
"description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness",
"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-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.17.2"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
+377
View File
@@ -0,0 +1,377 @@
/** Pure replay fold and strict decoder for durable goal changes. */
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { renderGoalChange } from './render.ts'
import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts'
import type {
FoldedGoal,
GoalBlockReason,
GoalChangeMeta,
GoalClearChangeMeta,
GoalMessageSource,
GoalOperation,
GoalPhase,
GoalRef,
GoalSnapshot,
GoalSnapshotChangeMeta,
} from './types.ts'
type ContextMessageEvent = Extract<SessionEvent, { type: 'context/message' }>
const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Set([
'create',
'edit',
'pause',
'resume',
'complete',
'block',
])
const PHASES: ReadonlySet<GoalPhase> = new Set(['active', 'paused', 'blocked', 'complete'])
/** Mutable accumulator kept private to the pure fold. */
export interface GoalFoldState {
goal: GoalSnapshot | undefined
roundsStarted: number
createdAt: number | undefined
updatedAt: number | undefined
lastRef: GoalRef | undefined
seenGoalIds: Set<GoalSnapshot['id']>
}
/**
* Build an empty replay accumulator.
* @returns mutable state with no current goal or prior ref.
*/
export function emptyGoalFoldState(): GoalFoldState {
return {
goal: undefined,
roundsStarted: 0,
createdAt: undefined,
updatedAt: undefined,
lastRef: undefined,
seenGoalIds: new Set(),
}
}
/** Whether a value is a JSON record rather than an array. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Require one positive safe integer. */
function positiveInteger(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
throw new Error(`goal change ${field} must be a positive safe integer`)
}
return value
}
/** Require one non-negative safe integer. */
function nonNegativeInteger(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
throw new Error(`goal change ${field} must be a non-negative safe integer`)
}
return value
}
/** Decode one canonical blocker explanation. */
function decodeBlockReason(value: unknown): GoalBlockReason {
if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'code,message') {
throw new Error('goal change goal.blockedReason has an invalid shape')
}
if (typeof value['code'] !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value['code'])) {
throw new Error('goal change goal.blockedReason.code must be lower-kebab-case')
}
if (typeof value['message'] !== 'string' || value['message'].trim().length === 0
|| value['message'] !== value['message'].trim()) {
throw new Error('goal change goal.blockedReason.message must be non-empty and normalized')
}
return { code: value['code'], message: value['message'] }
}
/** Decode and validate one snapshot. */
function decodeSnapshot(value: unknown): GoalSnapshot {
if (!isRecord(value)) throw new Error('goal change goal must be a record')
if (typeof value['id'] !== 'string' || value['id'].length === 0) {
throw new Error('goal change goal.id must be a non-empty string')
}
if (typeof value['objective'] !== 'string' || value['objective'].trim().length === 0
|| value['objective'] !== value['objective'].trim()) {
throw new Error('goal change goal.objective must be non-empty and normalized')
}
if (typeof value['phase'] !== 'string' || !PHASES.has(value['phase'] as GoalPhase)) {
throw new Error('goal change goal.phase is invalid')
}
const phase = value['phase'] as GoalPhase
const expectedKeys = phase === 'blocked'
? 'blockedReason,id,maxGoalRounds,objective,phase,revision'
: 'id,maxGoalRounds,objective,phase,revision'
if (Object.keys(value).sort().join(',') !== expectedKeys) {
throw new Error('goal change goal has an invalid shape')
}
return {
id: GoalId(value['id']),
revision: positiveInteger(value['revision'], 'goal.revision'),
objective: value['objective'],
phase,
maxGoalRounds: positiveInteger(value['maxGoalRounds'], 'goal.maxGoalRounds'),
...phase === 'blocked' ? { blockedReason: decodeBlockReason(value['blockedReason']) } : {},
}
}
/** Decode and validate one ref. */
function decodeRef(value: unknown): GoalRef {
if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'id,revision') {
throw new Error('goal clear tombstone has an invalid shape')
}
if (typeof value['id'] !== 'string' || value['id'].length === 0) {
throw new Error('goal clear tombstone id must be a non-empty string')
}
return { id: GoalId(value['id']), revision: positiveInteger(value['revision'], 'cleared.revision') }
}
/**
* Decode metadata that declares itself as a goal change. Unrelated metadata
* returns `undefined`; malformed goal metadata fails replay loudly.
* @param value - context-message metadata.
* @returns validated goal change or `undefined` for another metadata kind.
*/
export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined {
if (!isRecord(value) || value['kind'] !== 'goal/change') return undefined
if (value['version'] !== GOAL_CHANGE_VERSION) {
throw new Error(`unsupported goal change version ${String(value['version'])}`)
}
if (value['operation'] === 'clear') {
const allowed = ['cleared', 'clearedAt', 'kind', 'operation', 'version']
if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) {
throw new Error('goal clear change has an invalid shape')
}
return {
kind: 'goal/change',
version: GOAL_CHANGE_VERSION,
operation: 'clear',
cleared: decodeRef(value['cleared']),
clearedAt: nonNegativeInteger(value['clearedAt'], 'clearedAt'),
} satisfies GoalClearChangeMeta
}
if (typeof value['operation'] !== 'string'
|| !SNAPSHOT_OPERATIONS.has(value['operation'] as Exclude<GoalOperation, 'clear'>)) {
throw new Error('goal change operation is invalid')
}
const allowed = ['createdAt', 'goal', 'kind', 'operation', 'roundsStarted', 'updatedAt', 'version']
if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) {
throw new Error('goal snapshot change has an invalid shape')
}
const createdAt = nonNegativeInteger(value['createdAt'], 'createdAt')
const updatedAt = nonNegativeInteger(value['updatedAt'], 'updatedAt')
if (updatedAt < createdAt) throw new Error('goal change updatedAt cannot precede createdAt')
return {
kind: 'goal/change',
version: GOAL_CHANGE_VERSION,
operation: value['operation'] as Exclude<GoalOperation, 'clear'>,
goal: decodeSnapshot(value['goal']),
roundsStarted: nonNegativeInteger(value['roundsStarted'], 'roundsStarted'),
createdAt,
updatedAt,
} satisfies GoalSnapshotChangeMeta
}
/** Narrow model attribution to a valid goal source. */
function goalSource(source: MessageSource): GoalMessageSource | undefined {
if (source.kind !== 'goal') return undefined
if (typeof source.goalId !== 'string' || source.goalId.length === 0
|| !Number.isSafeInteger(source.revision) || source.revision < 1
|| !Number.isSafeInteger(source.round) || source.round < 0) {
throw new Error('goal message source is invalid')
}
return source
}
/** Require two snapshots to retain fields that only `edit` may replace. */
function requireSameDefinition(current: GoalSnapshot, next: GoalSnapshot, operation: GoalOperation): void {
if (next.objective !== current.objective || next.maxGoalRounds !== current.maxGoalRounds) {
throw new Error(`goal ${operation} cannot change objective or maxGoalRounds`)
}
}
/** Require one exact next revision of the current goal. */
function requireNextRevision(current: GoalSnapshot, next: GoalRef, operation: GoalOperation): void {
if (next.id !== current.id || next.revision !== current.revision + 1) {
throw new Error(`goal ${operation} must advance the current goal by one revision`)
}
}
/** Validate one non-create snapshot operation against the preceding projection. */
function validateSnapshotTransition(
state: GoalFoldState,
change: GoalSnapshotChangeMeta,
current: GoalSnapshot,
): void {
const next = change.goal
requireNextRevision(current, next, change.operation)
/* v8 ignore next -- a current goal established by this fold always has an updatedAt */
if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt')
if (change.createdAt !== state.createdAt
|| change.updatedAt < state.updatedAt
|| change.roundsStarted !== state.roundsStarted) {
throw new Error(`goal ${change.operation} does not preserve the current counters and timestamps`)
}
switch (change.operation) {
case 'edit':
if (next.phase !== current.phase
|| JSON.stringify(next.blockedReason) !== JSON.stringify(current.blockedReason)) {
throw new Error('goal edit cannot change phase or blocked reason')
}
break
case 'pause':
requireSameDefinition(current, next, change.operation)
if (current.phase !== 'active' || next.phase !== 'paused') throw new Error('goal pause has an invalid phase transition')
break
case 'resume': {
requireSameDefinition(current, next, change.operation)
const resumable: ReadonlySet<GoalPhase> = new Set([
'active',
'paused',
'blocked',
])
if (!resumable.has(current.phase) || next.phase !== 'active' || state.roundsStarted >= next.maxGoalRounds) {
throw new Error('goal resume has an invalid phase transition or exhausted round budget')
}
break
}
case 'complete':
requireSameDefinition(current, next, change.operation)
if (current.phase === 'complete' || next.phase !== 'complete') throw new Error('goal complete has an invalid phase transition')
break
case 'block':
requireSameDefinition(current, next, change.operation)
if (current.phase !== 'active' || next.phase !== 'blocked') throw new Error('goal block has an invalid phase transition')
break
/* v8 ignore start -- the caller excludes create and GoalOperation is closed; these arms retain fail-loud exhaustiveness */
case 'create':
throw new Error('goal create cannot be validated as a current-goal transition')
default:
change.operation satisfies never
throw new Error('unknown goal snapshot operation')
/* v8 ignore stop */
}
}
/**
* Return the revision identity carried by a snapshot or tombstone.
* @param change - decoded goal mutation.
* @returns stable identity used to reconcile a deferred change with its log event.
*/
export function goalChangeRef(change: GoalChangeMeta): GoalRef {
return change.operation === 'clear' ? change.cleared : change.goal
}
/**
* Validate and apply one decoded change to a mutable accumulator.
* @param state - preceding durable goal projection.
* @param change - decoded full snapshot or clear tombstone.
*/
export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): void {
const ref = goalChangeRef(change)
if (change.operation === 'clear') {
const current = state.goal
if (current === undefined) throw new Error('goal clear requires a current goal')
requireNextRevision(current, change.cleared, change.operation)
/* v8 ignore next -- a current goal established by this fold always has an updatedAt */
if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt')
if (change.clearedAt < state.updatedAt) {
throw new Error('goal clear timestamp cannot precede the current goal update')
}
state.goal = undefined
state.roundsStarted = 0
state.createdAt = undefined
state.updatedAt = undefined
state.lastRef = ref
return
}
if (change.operation === 'create') {
if (change.goal.revision !== 1 || change.goal.phase !== 'active' || change.roundsStarted !== 0
|| (state.goal !== undefined && state.goal.phase !== 'complete')
|| state.seenGoalIds.has(change.goal.id)) {
throw new Error('goal create requires a fresh active revision-one goal with zero rounds')
}
state.seenGoalIds.add(change.goal.id)
} else {
const current = state.goal
if (current === undefined) throw new Error(`goal ${change.operation} requires a current goal`)
validateSnapshotTransition(state, change, current)
}
state.goal = change.goal
state.roundsStarted = change.roundsStarted
state.createdAt = change.createdAt
state.updatedAt = change.updatedAt
state.lastRef = ref
}
/**
* Decode and verify one model-visible goal context event without folding it.
* @param event - context event whose metadata and rendered content must agree.
* @returns validated change or `undefined` for an unrelated context event.
*/
export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | undefined {
const change = decodeGoalChange(event.data.meta)
const source = goalSource(event.data.source)
if (change === undefined) {
if (source !== undefined) throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`)
return undefined
}
const ref = goalChangeRef(change)
if (source === undefined || source.goalId !== ref.id || source.revision !== ref.revision || source.round !== 0) {
throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`)
}
if (JSON.stringify(event.data.content) !== JSON.stringify(renderGoalChange(change))) {
throw new Error(`goal change at session event ${event.seq} has mismatched model-visible content`)
}
return change
}
/**
* Apply one session event and return its goal change, when present.
* @param state - mutable fold accumulator.
* @param event - next event in sequence order.
* @returns decoded change for pending-overlay reconciliation.
*/
export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalChangeMeta | undefined {
if (event.type === 'context/message') {
const change = decodeGoalEvent(event)
if (change === undefined) return undefined
applyGoalChange(state, change)
return change
}
if (event.type === 'user/message') {
const source = goalSource(event.data.source)
if (source !== undefined) {
const current = state.goal
if (current === undefined || current.phase !== 'active' || source.goalId !== current.id
|| source.revision !== current.revision || source.round !== state.roundsStarted + 1
|| source.round > current.maxGoalRounds) {
throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`)
}
state.roundsStarted = source.round
}
}
return undefined
}
/**
* Fold current goal state from a contiguous session event log.
* @param events - session events in sequence order.
* @returns a fresh durable projection; activation is deliberately absent.
*/
export function foldGoal(events: readonly SessionEvent[]): FoldedGoal {
const state = emptyGoalFoldState()
for (const event of events) applyGoalEvent(state, event)
return {
...state.goal === undefined ? {} : { goal: { ...state.goal } },
roundsStarted: state.roundsStarted,
...state.createdAt === undefined ? {} : { createdAt: state.createdAt },
...state.updatedAt === undefined ? {} : { updatedAt: state.updatedAt },
...state.lastRef === undefined ? {} : { lastRef: { ...state.lastRef } },
}
}
+529
View File
@@ -0,0 +1,529 @@
/**
* Same-session goal domain: event-sourced state, compare-and-set mutations,
* and process-local continuation activation.
* @module @deepseek-ai/dsh-goal
*/
import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue, Session } from '@deepseek-ai/dsh-session'
import {
applyGoalChange,
applyGoalEvent,
decodeGoalEvent,
emptyGoalFoldState,
goalChangeRef,
} from './fold.ts'
import type { GoalFoldState } from './fold.ts'
import { renderGoalChange } from './render.ts'
import {
GOAL_CHANGE_VERSION,
GoalError,
GoalId,
} from './runtime.ts'
import type {
CreateGoalRequest,
EditGoalRequest,
GoalActivation,
GoalBlockReason,
GoalChangeMeta,
GoalChanged,
GoalClearChangeMeta,
GoalOperation,
GoalPhase,
GoalRef,
GoalSnapshot,
GoalSnapshotChangeMeta,
GoalView,
} from './types.ts'
export * from './types.ts'
export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts'
export { decodeGoalChange, foldGoal, goalChangeRef } from './fold.ts'
export { renderGoalChange } from './render.ts'
declare module 'cordis' {
interface Context {
goals: GoalService
}
}
/** Deployment defaults for goal creation. */
export interface Config {
/** Total rounds used when a create request omits its own cap. */
defaultMaxGoalRounds?: number
}
/** Resolved defaults. */
export interface ResolvedConfig {
/** Validated positive safe-integer default round cap. */
defaultMaxGoalRounds: number
}
/** One accepted mutation waiting to enter or be observed in the session log. */
interface PendingGoalChange {
readonly change: GoalChangeMeta
readonly activation: GoalActivation
applied: boolean
}
/** Process-local cache plus mutations waiting in the active tool-batch FIFO. */
interface GoalCache {
readonly state: GoalFoldState
activation: GoalActivation
observedSeq: number
readonly pending: PendingGoalChange[]
}
/** Validated create input with every deployment default materialized. */
interface ResolvedCreateGoal {
readonly objective: string
readonly maxGoalRounds: number
}
/** Validate a caller-visible positive safe-integer round cap. */
function resolveMaxGoalRounds(value: number): number {
if (!Number.isSafeInteger(value) || value < 1) {
throw new GoalError('maxGoalRounds must be a positive safe integer', 'GOAL_INVALID_MAX_ROUNDS')
}
return value
}
/** Validate and normalize an objective at the domain boundary. */
function resolveObjective(value: string): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new GoalError('goal objective must be a non-empty string', 'GOAL_INVALID_OBJECTIVE')
}
return value.trim()
}
/** Materialize deployment defaults and validate one create request. */
function resolveCreateGoal(request: CreateGoalRequest, defaultMaxGoalRounds: number): ResolvedCreateGoal {
return {
objective: resolveObjective(request.objective),
maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? defaultMaxGoalRounds),
}
}
/** Validate and detach one policy-owned blocker explanation. */
function resolveBlockReason(reason: unknown): GoalBlockReason {
const record = typeof reason === 'object' && reason !== null && !Array.isArray(reason)
? reason as Record<string, unknown>
: undefined
const code = record?.['code']
const message = record?.['message']
if (typeof code !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(code)
|| typeof message !== 'string' || message.trim().length === 0) {
throw new GoalError(
'goal block reason requires a lower-kebab-case code and a non-empty message',
'GOAL_INVALID_BLOCK_REASON',
)
}
return { code, message: message.trim() }
}
/** Compare the complete canonical payloads used for deferred reconciliation. */
function sameChange(left: GoalChangeMeta, right: GoalChangeMeta): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
/** Goal service (`ctx.goals`) backed exclusively by the owning session log. */
export class GoalService extends Service {
static inject = ['agents']
static Config: z<Config> = z.object({
defaultMaxGoalRounds: z.number().default(256),
})
private readonly resolved: ResolvedConfig
private readonly caches = new WeakMap<Session, GoalCache>()
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'goals')
this.resolved = {
defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256),
}
ctx.on('agent/session-start', (agent) => {
this.cache(agent.session).activation = 'disarmed'
})
}
/**
* Read the current goal for one exact live agent.
* @param agent - owning live agent.
* @returns a fresh view or `undefined` when no goal is current.
* @throws {@link GoalError} when the agent is not the registry's live instance.
*/
get(agent: Agent): GoalView | undefined {
this.assertLive(agent)
const cache = this.cache(agent.session)
this.sync(agent.session, cache)
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.
* @param agent - owning live agent.
* @param request - objective and optional round cap.
* @returns the created live view.
*/
create(agent: Agent, request: CreateGoalRequest): GoalView {
const spec = resolveCreateGoal(request, this.resolved.defaultMaxGoalRounds)
const cache = this.prepareMutation(agent)
const current = cache.state.goal
if (current !== undefined && current.phase !== 'complete') {
throw new GoalError(`goal "${current.id}" already exists with phase "${current.phase}"`, 'GOAL_ALREADY_EXISTS')
}
const now = Date.now()
const goal: GoalSnapshot = {
id: GoalId(`goal-${randomUUID()}`),
revision: 1,
objective: spec.objective,
phase: 'active',
maxGoalRounds: spec.maxGoalRounds,
}
return this.commitSnapshot(agent, cache, 'create', goal, 0, now, now, 'armed')
}
/**
* Edit objective and/or round cap without changing phase.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @param request - at least one replacement field.
* @returns the edited view.
*/
edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
if (request.objective === undefined && request.maxGoalRounds === undefined) {
throw new GoalError('goal edit requires objective and/or maxGoalRounds', 'GOAL_INVALID_EDIT')
}
const goal: GoalSnapshot = {
...current,
revision: current.revision + 1,
...request.objective === undefined ? {} : { objective: resolveObjective(request.objective) },
...request.maxGoalRounds === undefined ? {} : { maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds) },
}
return this.commitCurrent(agent, cache, 'edit', goal, cache.activation)
}
/**
* Pause an active goal and disarm automatic continuation.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the paused view.
*/
pause(agent: Agent, ref: GoalRef): GoalView {
return this.transition(agent, ref, 'pause', ['active'], 'paused', 'disarmed')
}
/**
* Resume and arm a stopped goal, or rearm an active goal after a
* session-start edge, while its round budget still has capacity.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the active view.
*/
resume(agent: Agent, ref: GoalRef): GoalView {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
const resumable: readonly GoalPhase[] = ['active', 'paused', 'blocked']
if (!resumable.includes(current.phase)) {
throw this.transitionError(current, 'resume', resumable)
}
if (current.phase === 'active' && cache.activation === 'armed') {
throw new GoalError(`goal "${current.id}" is already active and armed`, 'GOAL_INVALID_TRANSITION')
}
if (cache.state.roundsStarted >= current.maxGoalRounds) {
throw new GoalError(
`goal "${current.id}" exhausted ${current.maxGoalRounds} goal rounds; increase maxGoalRounds before resuming`,
'GOAL_INVALID_TRANSITION',
)
}
return this.commitCurrent(agent, cache, 'resume', this.withPhase(current, 'active'), 'armed')
}
/**
* Mark a current non-complete goal complete and disarm it.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the completed view.
*/
complete(agent: Agent, ref: GoalRef): GoalView {
return this.transition(
agent,
ref,
'complete',
['active', 'paused', 'blocked'],
'complete',
'disarmed',
)
}
/**
* Mark an active goal blocked and disarm it.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @param reason - policy-owned stable code and human-readable explanation.
* @returns the blocked view with its durable reason.
*/
block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
if (current.phase !== 'active') {
throw this.transitionError(current, 'block', ['active'])
}
return this.commitCurrent(
agent,
cache,
'block',
{ ...this.withPhase(current, 'blocked'), blockedReason: resolveBlockReason(reason) },
'disarmed',
)
}
/**
* Clear the current goal while retaining a durable tombstone and history.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the tombstone ref whose revision is one past the cleared snapshot.
*/
clear(agent: Agent, ref: GoalRef): GoalRef {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
const tombstone: GoalRef = { id: current.id, revision: current.revision + 1 }
const change: GoalClearChangeMeta = {
kind: 'goal/change',
version: GOAL_CHANGE_VERSION,
operation: 'clear',
cleared: tombstone,
clearedAt: this.nextMutationTime(cache),
}
this.commit(agent, cache, change, 'disarmed')
return { ...tombstone }
}
/** Resolve and validate the cache used by a mutation. */
private prepareMutation(agent: Agent): GoalCache {
this.assertLive(agent)
const cache = this.cache(agent.session)
this.sync(agent.session, cache)
return cache
}
/** Reject stale or missing current-state refs. */
private expectCurrent(cache: GoalCache, ref: GoalRef): GoalSnapshot {
const current = cache.state.goal
if (current === undefined) throw new GoalError('no current goal', 'GOAL_NOT_FOUND')
if (ref.id !== current.id || ref.revision !== current.revision) {
throw new GoalError(
`stale goal ref "${ref.id}" revision ${ref.revision}; current is "${current.id}" revision ${current.revision}`,
'GOAL_STALE_REVISION',
)
}
return current
}
/** Enforce exact live-agent identity rather than trusting a matching id. */
private assertLive(agent: Agent): void {
if (this.ctx.agents.get(agent.id) !== agent || agent.status === 'disposed') {
throw new GoalError(`agent "${agent.id}" is not live in this registry`, 'GOAL_AGENT_NOT_LIVE')
}
}
/** Return the per-session cache, folding a seed once with activation disarmed. */
private cache(session: Session): GoalCache {
let cache = this.caches.get(session)
if (cache !== undefined) return cache
const state = emptyGoalFoldState()
for (const event of session.events) applyGoalEvent(state, event)
cache = {
state,
activation: 'disarmed',
observedSeq: session.seq,
pending: [],
}
this.caches.set(session, cache)
return cache
}
/** Incrementally observe durable events without losing deferred mutations. */
private sync(session: Session, cache: GoalCache): void {
for (const event of session.events.slice(cache.observedSeq)) {
if (event.type === 'context/message') {
const change = decodeGoalEvent(event)
if (change !== undefined) {
const pending = cache.pending[0]
if (pending !== undefined && sameChange(pending.change, change)) {
if (!pending.applied) {
applyGoalChange(cache.state, change)
cache.activation = pending.activation
pending.applied = true
}
cache.pending.shift()
cache.observedSeq += 1
continue
}
}
}
applyGoalEvent(cache.state, event)
cache.observedSeq += 1
}
}
/** Build a new revision with one replacement phase. */
private withPhase(current: GoalSnapshot, phase: GoalPhase): GoalSnapshot {
return {
id: current.id,
revision: current.revision + 1,
objective: current.objective,
phase,
maxGoalRounds: current.maxGoalRounds,
}
}
/** Shared validated phase transition. */
private transition(
agent: Agent,
ref: GoalRef,
operation: Exclude<GoalOperation, 'create' | 'edit' | 'clear'>,
allowed: readonly GoalPhase[],
phase: GoalPhase,
activation: GoalActivation,
): GoalView {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
if (!allowed.includes(current.phase)) throw this.transitionError(current, operation, allowed)
return this.commitCurrent(agent, cache, operation, this.withPhase(current, phase), activation)
}
/** Render a stable invalid-transition error. */
private transitionError(current: GoalSnapshot, operation: GoalOperation, allowed: readonly GoalPhase[]): GoalError {
return new GoalError(
`cannot ${operation} goal "${current.id}" from phase "${current.phase}"; expected ${allowed.join(' or ')}`,
'GOAL_INVALID_TRANSITION',
)
}
/** Commit a mutation that retains the current goal's derived counters/times. */
private commitCurrent(
agent: Agent,
cache: GoalCache,
operation: Exclude<GoalOperation, 'create' | 'clear'>,
goal: GoalSnapshot,
activation: GoalActivation,
): GoalView {
const createdAt = cache.state.createdAt
/* v8 ignore next -- strict replay and every snapshot commit set createdAt whenever a current goal exists */
if (createdAt === undefined) throw new Error('current goal cache lacks createdAt')
return this.commitSnapshot(
agent,
cache,
operation,
goal,
cache.state.roundsStarted,
createdAt,
this.nextMutationTime(cache),
activation,
)
}
/** Clamp a current goal's next timestamp across backward wall-clock movement. */
private nextMutationTime(cache: GoalCache): number {
const updatedAt = cache.state.updatedAt
/* v8 ignore next -- strict replay and every snapshot commit set updatedAt whenever a current goal exists */
if (updatedAt === undefined) throw new Error('current goal cache lacks updatedAt')
return Math.max(Date.now(), updatedAt)
}
/** Build and commit one full-snapshot mutation. */
private commitSnapshot(
agent: Agent,
cache: GoalCache,
operation: Exclude<GoalOperation, 'clear'>,
goal: GoalSnapshot,
roundsStarted: number,
createdAt: number,
updatedAt: number,
activation: GoalActivation,
): GoalView {
const change: GoalSnapshotChangeMeta = {
kind: 'goal/change',
version: GOAL_CHANGE_VERSION,
operation,
goal,
roundsStarted,
createdAt,
updatedAt,
}
this.commit(agent, cache, change, activation)
const view = this.view(cache)
/* v8 ignore next -- applyGoalChange installs the snapshot immediately before this read */
if (view === undefined) throw new Error('snapshot commit cleared the goal unexpectedly')
return view
}
/** Accept one mutation into the agent log/FIFO, cache, and live event stream. */
private commit(agent: Agent, cache: GoalCache, change: GoalChangeMeta, activation: GoalActivation): void {
const ref = goalChangeRef(change)
// snapshotJsonValue preserves its input type for callers that already have
// a JsonValue; this interface is structurally JSON but intentionally has no
// index signature, so narrow the validated output at this boundary.
const meta = snapshotJsonValue(change) as JsonValue | undefined
/* v8 ignore next -- validated goal changes contain only finite JSON primitives and records */
if (meta === undefined) throw new Error('goal change is not losslessly JSON-serializable')
const pending: PendingGoalChange = { change, activation, applied: false }
cache.pending.push(pending)
try {
agent.inject(renderGoalChange(change), {
source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0 },
meta,
})
} catch (error: unknown) {
const index = cache.pending.indexOf(pending)
/* v8 ignore next -- a committed goal append cannot reject after its contained observers run */
if (index < 0) throw new Error('goal injection failed after its pending mutation was reconciled', { cause: error })
cache.pending.splice(index, 1)
throw error
}
if (!pending.applied) {
applyGoalChange(cache.state, change)
cache.activation = activation
pending.applied = true
}
this.sync(agent.session, cache)
const goal = this.view(cache)
const notification: GoalChanged = {
operation: change.operation,
ref: { ...ref },
...goal === undefined ? {} : { goal },
}
agentEvents(this.ctx, agent).emit('goal/changed', notification)
}
/** Build a detached current view. */
private view(cache: GoalCache): GoalView | undefined {
const goal = cache.state.goal
const createdAt = cache.state.createdAt
const updatedAt = cache.state.updatedAt
if (goal === undefined) return undefined
/* v8 ignore next 3 -- strict replay and snapshot commits establish both timestamps with every current goal */
if (createdAt === undefined || updatedAt === undefined) {
throw new Error(`goal "${goal.id}" cache lacks timestamps`)
}
return {
...goal,
roundsStarted: cache.state.roundsStarted,
createdAt,
updatedAt,
activation: cache.activation,
}
}
}
export default GoalService
+21
View File
@@ -0,0 +1,21 @@
/** Model-visible rendering for durable goal mutations. */
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { GoalChangeMeta } from './types.ts'
/**
* Render a complete goal snapshot or clear tombstone without hidden prose.
* @param change - durable goal change metadata.
* @returns the single context block logged and projected verbatim for model reconstruction.
*/
export function renderGoalChange(change: GoalChangeMeta): ContentBlock[] {
const payload = change.operation === 'clear'
? { cleared: change.cleared, clearedAt: change.clearedAt }
: {
goal: change.goal,
roundsStarted: change.roundsStarted,
createdAt: change.createdAt,
updatedAt: change.updatedAt,
}
return [{ type: 'text', text: `<goal_state>${JSON.stringify(payload)}</goal_state>` }]
}
+29
View File
@@ -0,0 +1,29 @@
/** Runtime constructors and protocol constants for the goal domain. */
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts'
/** Version of the goal change metadata embedded in `context/message`. */
export const GOAL_CHANGE_VERSION = 1
/**
* Brand a string as a goal id.
* @param id - raw goal identifier.
* @returns the same string with the compile-time brand.
*/
export function GoalId(id: string): GoalIdType {
return id as GoalIdType
}
/** Error returned by the goal domain boundary. */
export class GoalError extends HarnessError {
/**
* @param message - human-readable rejection reason.
* @param code - stable machine-routable classification.
*/
// Keep the constructor to narrow HarnessError's string code at this boundary.
// eslint-disable-next-line @typescript-eslint/no-useless-constructor -- type-only narrowing
constructor(message: string, code: GoalErrorCode) {
super(message, code)
}
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Durable and live vocabulary for one same-session goal.
* @module @deepseek-ai/dsh-goal/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Agent } from '@deepseek-ai/dsh-agent'
/** Identifies one goal across its durable revisions. */
export type GoalId = Branded<'GoalId'>
/** Compare-and-set identity for one exact goal revision. */
export interface GoalRef {
/** Stable goal identity. */
readonly id: GoalId
/** Positive revision; every durable mutation increments it. */
readonly revision: number
}
/** Durable continuation phase. Activation is process-local and separate. */
export type GoalPhase =
| 'active'
| 'paused'
| 'blocked'
| 'complete'
/** Machine-routable and human-readable explanation for a blocked goal. */
export interface GoalBlockReason {
/** Stable lower-kebab-case classification chosen by the blocking policy. */
readonly code: string
/** Non-empty explanation shown to humans and models. */
readonly message: string
}
/** Full durable state written by every non-clear goal mutation. */
export interface GoalSnapshot extends GoalRef {
/** Human-requested completion objective. */
readonly objective: string
/** Durable lifecycle phase. */
readonly phase: GoalPhase
/** Present exactly while `phase` is `blocked`. */
readonly blockedReason?: GoalBlockReason
/** Total admitted goal-round cap. */
readonly maxGoalRounds: number
}
/** Whether this live process may automatically continue an active goal. */
export type GoalActivation = 'armed' | 'disarmed'
/** Current goal projection, including values derived from the session log. */
export interface GoalView extends GoalSnapshot {
/** Highest admitted round number for this goal. */
readonly roundsStarted: number
/** Epoch milliseconds of the create mutation. */
readonly createdAt: number
/** Epoch milliseconds of the latest mutation. */
readonly updatedAt: number
/** Process-local continuation eligibility; never persisted. */
readonly activation: GoalActivation
}
/** Goal state-changing verbs recorded in the durable change metadata. */
export type GoalOperation =
| 'create'
| 'edit'
| 'pause'
| 'resume'
| 'complete'
| 'block'
| 'clear'
/** Full-snapshot goal mutation retained in a model-visible context event. */
export interface GoalSnapshotChangeMeta {
readonly kind: 'goal/change'
readonly version: 1
readonly operation: Exclude<GoalOperation, 'clear'>
readonly goal: GoalSnapshot
readonly roundsStarted: number
readonly createdAt: number
readonly updatedAt: number
}
/** Tombstone retained when the current goal is cleared. */
export interface GoalClearChangeMeta {
readonly kind: 'goal/change'
readonly version: 1
readonly operation: 'clear'
readonly cleared: GoalRef
readonly clearedAt: number
}
/** Durable metadata union carried by a goal-owned `context/message`. */
export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
/** Message attribution for durable goal state and continuation rounds. */
export interface GoalMessageSource {
readonly kind: 'goal'
readonly goalId: GoalId
readonly revision: number
/** Zero for state changes; positive for admitted continuation rounds. */
readonly round: number
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
goal: GoalMessageSource
}
}
/** Pure replay fold of durable goal facts. */
export interface FoldedGoal {
/** Current goal, absent after a clear or before the first create. */
readonly goal?: GoalSnapshot
/** Highest admitted round for the current goal. */
readonly roundsStarted: number
/** Current goal creation time, absent without a current goal. */
readonly createdAt?: number
/** Current goal mutation time, absent without a current goal. */
readonly updatedAt?: number
/** Latest mutation ref, including a clear tombstone. */
readonly lastRef?: GoalRef
}
/** Input whose omitted round cap is resolved by the service configuration. */
export interface CreateGoalRequest {
readonly objective: string
readonly maxGoalRounds?: number
}
/** Fields changed by an edit; at least one must be present. */
export interface EditGoalRequest {
readonly objective?: string
readonly maxGoalRounds?: number
}
/** Live notification after one goal mutation has been accepted for logging. */
export interface GoalChanged {
readonly operation: GoalOperation
readonly ref: GoalRef
/** Absent for a clear tombstone. */
readonly goal?: GoalView
}
/** Stable error codes for rejected goal reads and mutations. */
export type GoalErrorCode =
| 'GOAL_AGENT_NOT_LIVE'
| 'GOAL_NOT_FOUND'
| 'GOAL_ALREADY_EXISTS'
| 'GOAL_STALE_REVISION'
| 'GOAL_INVALID_OBJECTIVE'
| 'GOAL_INVALID_MAX_ROUNDS'
| 'GOAL_INVALID_BLOCK_REASON'
| 'GOAL_INVALID_EDIT'
| 'GOAL_INVALID_TRANSITION'
declare module 'cordis' {
interface Events {
/**
* Goal mutation accepted by one live agent. The matching context event is
* already appended or queued in that agent's active tool-batch FIFO.
* Listener failures are contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @param agent - agent whose session owns the goal.
* @param change - fresh current projection or clear tombstone.
* @mode emit
*/
'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, agent: Agent, change: GoalChanged): void
}
}
+75
View File
@@ -0,0 +1,75 @@
import { readFile, readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { decodeGoalChange, renderGoalChange } from '@deepseek-ai/dsh-goal'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL(
'../../../../examples/headless-agent/tests/fixtures/goal-domain/cordis.yml',
import.meta.url,
))
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
async function jsonlFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true })
const paths = await Promise.all(entries.map(async (entry) => {
const path = join(dir, entry.name)
if (entry.isDirectory()) return jsonlFiles(path)
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
}))
return paths.flat()
}
describe('goal domain through a real cordis.yml and headless process', () => {
it('persists the Loader-mounted snapshot without starting a goal round', async () => {
let events: SessionEvent[] = []
const { stdout, stderr } = await runLoaderSmoke({
label: 'goal-domain',
tempDirPrefix: 'goal-domain-e2e-',
binScript,
configPath,
binArgs: ['--config', configPath, '--output-format', 'json', 'prove the persisted goal domain'],
tsconfigPath: repoTsconfig,
inspect: async (cwd) => {
const logs = await jsonlFiles(join(cwd, '.sessions'))
expect(logs).toHaveLength(1)
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
},
})
expect(stderr).toBe('')
const result = JSON.parse(stdout) as Record<string, unknown>
expect(result).toMatchObject({
type: 'result',
success: true,
})
expect(result['result']).toBeTypeOf('string')
expect(result['result']).toContain('CLI tool round trip complete')
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1)
const contexts = events.filter(event => event.type === 'context/message'
&& event.data.source.kind === 'goal')
expect(contexts).toHaveLength(1)
const context = contexts[0]
if (context?.type !== 'context/message') throw new Error('expected goal context event')
const change = decodeGoalChange(context.data.meta)
if (change === undefined) throw new Error('expected durable goal change')
expect(change).toMatchObject({
operation: 'create',
roundsStarted: 0,
goal: {
revision: 1,
objective: 'Prove the composed goal survives in the session log',
phase: 'active',
maxGoalRounds: 7,
},
})
expect(context.data.content).toEqual(renderGoalChange(change))
expect(JSON.stringify(context)).not.toContain('activation')
expect(events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'goal')).toHaveLength(0)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})
+851
View File
@@ -0,0 +1,851 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
import { HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import GoalService, {
GoalError,
GoalId,
decodeGoalChange,
foldGoal,
renderGoalChange,
} from '@deepseek-ai/dsh-goal'
import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
interface DeferredInjection {
content: ContentBlock[]
options: InjectOptions | undefined
}
interface StubAgent {
agent: Agent
session: Session
deferred: DeferredInjection[]
setDeferred(value: boolean): void
setStatus(value: AgentStatus): void
drain(): void
}
/** Number the next balanced one-shot injection turn. */
function nextTurn(session: Session): number {
return session.events.reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1
}
/** Mirror the public Agent.inject idle/open-turn contract for domain tests. */
function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void {
const source: MessageSource = options?.source ?? { kind: 'user' }
const context = {
content,
source,
...options?.meta === undefined ? {} : { meta: options.meta },
}
const last = session.events.at(-1)
const open = last !== undefined && last.type !== 'turn/end'
if (open) {
session.append('context/message', context, { surfaceOp: 'append' })
return
}
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', context, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
/** Build a registry-compatible agent around one concrete session. */
function stubAgentForSession(session: Session): StubAgent {
const id = session.id
const deferred: DeferredInjection[] = []
let shouldDefer = false
let status: AgentStatus = 'idle'
const agent: Agent = {
id,
options: {},
session,
ctx: new Context(),
get status() { return status },
send() {},
steer() {},
inject(content, options) {
if (shouldDefer) deferred.push({ content, options })
else appendInjection(session, content, options)
},
cancel() {},
whenIdle() { return Promise.resolve() },
}
return {
agent,
session,
deferred,
setDeferred(value) { shouldDefer = value },
setStatus(value) { status = value },
drain() {
shouldDefer = false
for (const injection of deferred.splice(0)) appendInjection(session, injection.content, injection.options)
},
}
}
/** Build a registry-compatible agent with controllable context deferral. */
function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[]): StubAgent {
return stubAgentForSession(new Session(SessionId(rawId), seed))
}
async function harness(config: { defaultMaxGoalRounds?: number } = {}) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService, config)
const stub = stubAgent(`goal-test-${Math.random()}`)
ctx.agents.register(stub.agent)
return { ctx, ...stub }
}
/** Append one admitted goal round as a balanced user-message turn. */
function appendRound(session: Session, ref: GoalRef, round: number): void {
const source = { kind: 'goal', goalId: ref.id, revision: ref.revision, round } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
session.append('user/message', { content: [{ type: 'text', text: `round ${round}` }], source }, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
describe('GoalService creation and replay', () => {
it('applies the configured default and writes one balanced verbatim context snapshot', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_700_000_000_000)
const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 })
const seen: string[] = []
ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) })
const goal = ctx.goals.create(agent, { objective: ' finish the feature ' })
expect(goal).toMatchObject({
objective: 'finish the feature',
phase: 'active',
revision: 1,
maxGoalRounds: 17,
roundsStarted: 0,
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
activation: 'armed',
})
expect(goal.id).toMatch(/^goal-/)
expect(seen).toEqual(['create'])
expect(session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end'])
const context = session.events[1]
expect(context?.type).toBe('context/message')
if (context?.type !== 'context/message') throw new Error('expected goal context')
expect(context.data.source).toEqual({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 })
const change = decodeGoalChange(context.data.meta)
if (change === undefined) throw new Error('expected decoded goal change')
expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } })
expect(context.data.content).toEqual(renderGoalChange(change))
expect(session.deriveMessages()).toEqual([{ role: 'user', content: context.data.content }])
expect(foldGoal(session.events)).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 })
vi.useRealTimers()
})
it('uses 256 rounds by default and validates create input inside create', async () => {
const { ctx, agent } = await harness()
expect(() => ctx.goals.create(agent, { objective: ' ' })).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_OBJECTIVE',
}))
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 0 })).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_MAX_ROUNDS',
}))
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError)
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 1.5 })).toThrow(HarnessError)
expect(() => ctx.goals.create(agent, {
objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1,
})).toThrow(GoalError)
expect(ctx.goals.create(agent, { objective: 'x' }).maxGoalRounds).toBe(256)
})
it('also resolves the default when constructed directly without Cordis config normalization', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const goals = new GoalService(ctx)
const stub = stubAgent('goal-direct-construction')
ctx.agents.register(stub.agent)
expect(goals.create(stub.agent, { objective: 'direct' })).toMatchObject({
objective: 'direct', maxGoalRounds: 256,
})
})
it('rejects invalid direct configuration', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await expect(ctx.plugin(GoalService, { defaultMaxGoalRounds: -1 })).rejects.toThrow(expect.objectContaining({
code: 'GOAL_INVALID_MAX_ROUNDS',
}))
})
it('restores a seeded goal and rounds with activation disarmed', async () => {
const first = await harness()
const created = first.ctx.goals.create(first.agent, { objective: 'seed me', maxGoalRounds: 9 })
appendRound(first.session, created, 1)
appendRound(first.session, created, 2)
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const resumed = stubAgent('seeded-goal', first.session.events)
ctx.agents.register(resumed.agent)
expect(ctx.goals.get(resumed.agent)).toMatchObject({
id: created.id,
roundsStarted: 2,
activation: 'disarmed',
})
})
it('inherits the completed-turn goal prefix through SessionStore.fork with child activation disarmed', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent')))
ctx.agents.register(parent.agent)
const goal = ctx.goals.create(parent.agent, { objective: 'inherit through fork', maxGoalRounds: 5 })
appendRound(parent.session, goal, 1)
const child = stubAgentForSession(ctx.sessions.fork(parent.session))
ctx.agents.register(child.agent)
expect(ctx.goals.get(child.agent)).toMatchObject({
id: goal.id,
objective: goal.objective,
roundsStarted: 1,
activation: 'disarmed',
})
expect(child.session.header.parentSession).toBe(parent.session.id)
expect(child.session.header.seedLength).toBe(parent.session.seq)
})
it('disarms live activation on every session-start edge', async () => {
const { ctx, agent, session } = await harness()
let goal = ctx.goals.create(agent, { objective: 'stay stopped after resume' })
expect(goal.activation).toBe('armed')
agentEvents(ctx, agent).emit('agent/session-start', 'resume')
expect(ctx.goals.get(agent)?.activation).toBe('disarmed')
goal = ctx.goals.resume(agent, goal)
expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 })
expect(() => foldGoal(session.events)).not.toThrow()
})
it('removes the service and its session-start listener with the providing fiber', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(GoalService)
const first = ctx.goals
const stub = stubAgent('goal-hmr')
ctx.agents.register(stub.agent)
const goal = first.create(stub.agent, { objective: 'survive service reload' })
await fiber.dispose()
expect(ctx.get('goals')).toBeUndefined()
agentEvents(ctx, stub.agent).emit('agent/session-start', 'resume')
expect(first.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'armed' })
await ctx.plugin(GoalService)
expect(ctx.goals).not.toBe(first)
expect(ctx.goals.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'disarmed' })
})
it('requires the exact live registry instance for reads and mutations', async () => {
const { ctx, agent } = await harness()
const impostor = { ...agent, session: new Session(agent.id) }
expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' }))
expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({
code: 'GOAL_AGENT_NOT_LIVE',
}))
})
it('rejects a disposed live object even before registry teardown', async () => {
const test = await harness()
test.setStatus('disposed')
expect(() => test.ctx.goals.get(test.agent)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' }))
})
})
describe('GoalService mutations', () => {
it('edits with compare-and-set revisions and rejects empty edits', async () => {
const { ctx, agent } = await harness()
const created = ctx.goals.create(agent, { objective: 'old', maxGoalRounds: 4 })
expect(() => ctx.goals.edit(agent, created, {})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_EDIT' }))
const objective = ctx.goals.edit(agent, created, { objective: ' new ' })
expect(objective).toMatchObject({ objective: 'new', maxGoalRounds: 4, revision: 2, activation: 'armed' })
expect(() => ctx.goals.edit(agent, created, { maxGoalRounds: 8 })).toThrow(expect.objectContaining({
code: 'GOAL_STALE_REVISION',
}))
const cap = ctx.goals.edit(agent, objective, { maxGoalRounds: 8 })
expect(cap).toMatchObject({ objective: 'new', maxGoalRounds: 8, revision: 3 })
expect(() => ctx.goals.edit(agent, cap, { objective: ' ' })).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_OBJECTIVE',
}))
})
it('supports pause, resume, block, and completion transitions', async () => {
const { ctx, agent } = await harness()
let goal = ctx.goals.create(agent, { objective: 'lifecycle' })
goal = ctx.goals.pause(agent, goal)
expect(goal).toMatchObject({ phase: 'paused', activation: 'disarmed', revision: 2 })
goal = ctx.goals.resume(agent, goal)
expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 3 })
goal = ctx.goals.block(agent, goal, { code: 'needs-input', message: 'A choice is required.' })
expect(goal).toMatchObject({
phase: 'blocked',
blockedReason: { code: 'needs-input', message: 'A choice is required.' },
activation: 'disarmed',
})
goal = ctx.goals.resume(agent, goal)
goal = ctx.goals.pause(agent, goal)
goal = ctx.goals.complete(agent, goal)
expect(goal).toMatchObject({ phase: 'complete', activation: 'disarmed' })
expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
})
it('allows completion from every stopped phase and replacement only after completion', async () => {
const phases = ['paused', 'blocked'] as const
for (const phase of phases) {
const { ctx, agent } = await harness()
let goal = ctx.goals.create(agent, { objective: phase })
goal = phase === 'paused'
? ctx.goals.pause(agent, goal)
: ctx.goals.block(agent, goal, { code: 'test-blocker', message: 'Blocked for the test.' })
const complete = ctx.goals.complete(agent, goal)
const replacement = ctx.goals.create(agent, { objective: `after ${phase}` })
expect(complete.phase).toBe('complete')
expect(replacement.id).not.toBe(complete.id)
expect(replacement.revision).toBe(1)
}
})
it('rejects replacement and invalid phase transitions while a resumable goal exists', async () => {
const { ctx, agent } = await harness()
const goal = ctx.goals.create(agent, { objective: 'still active' })
expect(() => ctx.goals.create(agent, { objective: 'replacement' })).toThrow(expect.objectContaining({
code: 'GOAL_ALREADY_EXISTS',
}))
expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
const paused = ctx.goals.pause(agent, goal)
expect(() => ctx.goals.pause(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
expect(() => ctx.goals.block(agent, paused, {
code: 'test-blocker', message: 'Blocked for the test.',
})).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_TRANSITION',
}))
})
it('records canonical blocker reasons and enforces the round cap on resume', async () => {
const { ctx, agent, session } = await harness()
let goal = ctx.goals.create(agent, { objective: 'bounded', maxGoalRounds: 2 })
for (const reason of [null, [], { code: 1, message: 'invalid code' }, { code: 'round-limit', message: 1 }]) {
expect(() => ctx.goals.block(agent, goal, reason as never)).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_BLOCK_REASON',
}))
}
expect(() => ctx.goals.block(agent, goal, {
code: 'Not Canonical', message: 'invalid code',
})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_BLOCK_REASON' }))
expect(() => ctx.goals.block(agent, goal, {
code: 'round-limit', message: ' ',
})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_BLOCK_REASON' }))
appendRound(session, goal, 1)
expect(ctx.goals.get(agent)?.roundsStarted).toBe(1)
appendRound(session, goal, 2)
goal = ctx.goals.block(agent, goal, { code: 'round-limit', message: ' Goal round limit reached. ' })
expect(goal).toMatchObject({
phase: 'blocked',
blockedReason: { code: 'round-limit', message: 'Goal round limit reached.' },
roundsStarted: 2,
activation: 'disarmed',
})
expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
goal = ctx.goals.edit(agent, goal, { maxGoalRounds: 3 })
expect(goal.blockedReason).toEqual({ code: 'round-limit', message: 'Goal round limit reached.' })
goal = ctx.goals.resume(agent, goal)
expect(goal).toMatchObject({ phase: 'active', maxGoalRounds: 3, activation: 'armed' })
expect(goal.blockedReason).toBeUndefined()
appendRound(session, goal, 3)
goal = ctx.goals.block(agent, goal, { code: 'round-limit', message: 'Goal round limit reached.' })
expect(ctx.goals.complete(agent, goal).phase).toBe('complete')
})
it('clears through a revisioned tombstone and permits a fresh goal', async () => {
const { ctx, agent, session } = await harness()
const goal = ctx.goals.create(agent, { objective: 'temporary' })
const tombstone = ctx.goals.clear(agent, goal)
expect(tombstone).toEqual({ id: goal.id, revision: 2 })
expect(ctx.goals.get(agent)).toBeUndefined()
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0, lastRef: tombstone })
expect(() => ctx.goals.clear(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_NOT_FOUND' }))
const next = ctx.goals.create(agent, { objective: 'fresh' })
expect(next.id).not.toBe(goal.id)
})
it('keeps per-goal mutation timestamps monotonic when the wall clock moves backward', async () => {
vi.useFakeTimers()
vi.setSystemTime(100)
const { ctx, agent, session } = await harness()
let goal = ctx.goals.create(agent, { objective: 'monotonic time' })
vi.setSystemTime(90)
goal = ctx.goals.pause(agent, goal)
expect(goal.updatedAt).toBe(100)
vi.setSystemTime(80)
ctx.goals.clear(agent, goal)
const clear = session.events
.filter(event => event.type === 'context/message')
.map(event => decodeGoalChange(event.data.meta))
.at(-1)
expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 })
expect(() => foldGoal(session.events)).not.toThrow()
vi.useRealTimers()
})
it('contains goal notification failures and preserves later listeners', async () => {
const { ctx, agent } = await harness()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const seen: string[] = []
ctx.on('goal/changed', () => { throw new Error('broken observer') })
ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) })
expect(ctx.goals.create(agent, { objective: 'notify' }).phase).toBe('active')
expect(seen).toEqual(['create'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer'))
})
it('preserves multiple pending revisions until deferred injections enter the log', async () => {
const test = await harness()
const { ctx, agent, session, deferred } = test
test.setDeferred(true)
let goal = ctx.goals.create(agent, { objective: 'deferred', maxGoalRounds: 5 })
goal = ctx.goals.edit(agent, goal, { objective: 'deferred edit' })
goal = ctx.goals.pause(agent, goal)
expect(goal).toMatchObject({ revision: 3, phase: 'paused', activation: 'disarmed' })
expect(deferred).toHaveLength(3)
expect(session.events).toHaveLength(0)
appendInjection(session, [{ type: 'text', text: 'unrelated' }], { source: { kind: 'plugin', plugin: 'test' } })
expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' })
test.drain()
expect(deferred).toHaveLength(0)
expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' })
expect(foldGoal(session.events)).toMatchObject({ goal: { revision: 3, phase: 'paused' } })
})
it('publishes a mutation consistently to a reentrant session observer', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer')))
ctx.agents.register(stub.agent)
let observed: ReturnType<GoalService['get']>
ctx.on('session/event', (session, event) => {
if (session === stub.session && event.type === 'context/message') observed = ctx.goals.get(stub.agent)
})
const created = ctx.goals.create(stub.agent, { objective: 'publish once' })
expect(observed).toEqual(created)
expect(ctx.goals.get(stub.agent)).toEqual(created)
expect(foldGoal(stub.session.events)).toMatchObject({ goal: { id: created.id, revision: 1 } })
})
it('rolls back a pending mutation when injection rejects before append', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const stub = stubAgent('goal-rejected-injection')
const append = stub.agent.inject.bind(stub.agent)
let reject = true
stub.agent.inject = (content, options) => {
if (reject) throw new Error('injection rejected')
append(content, options)
}
ctx.agents.register(stub.agent)
expect(() => ctx.goals.create(stub.agent, { objective: 'first attempt' })).toThrow('injection rejected')
reject = false
expect(ctx.goals.create(stub.agent, { objective: 'second attempt' })).toMatchObject({
objective: 'second attempt',
revision: 1,
})
})
it('rejects deferred goal mutations that enter the log out of FIFO order', async () => {
const test = await harness()
test.setDeferred(true)
const created = test.ctx.goals.create(test.agent, { objective: 'ordered' })
test.ctx.goals.edit(test.agent, created, { objective: 'ordered edit' })
const second = test.deferred[1]
if (second === undefined) throw new Error('expected a second deferred goal mutation')
appendInjection(test.session, second.content, second.options)
expect(() => test.ctx.goals.get(test.agent)).toThrow('advance the current goal')
})
it('observes a valid goal snapshot appended after an empty cache was established', async () => {
const { ctx, agent, session } = await harness()
expect(ctx.goals.get(agent)).toBeUndefined()
const change: GoalSnapshotChangeMeta = {
kind: 'goal/change',
version: 1,
operation: 'create',
goal: {
id: GoalId('goal-external'),
revision: 1,
objective: 'observe external append',
phase: 'active',
maxGoalRounds: 4,
},
roundsStarted: 0,
createdAt: 12,
updatedAt: 12,
}
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', {
content: renderGoalChange(change), source, meta: change as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
expect(ctx.goals.get(agent)).toMatchObject({
id: change.goal.id,
objective: change.goal.objective,
activation: 'disarmed',
})
})
it('reports the same corrupt unseen event after committing its valid prefix', async () => {
const { ctx, agent, session } = await harness()
expect(ctx.goals.get(agent)).toBeUndefined()
const change: GoalSnapshotChangeMeta = {
kind: 'goal/change',
version: 1,
operation: 'create',
goal: {
id: GoalId('goal-valid-prefix'),
revision: 1,
objective: 'valid prefix',
phase: 'active',
maxGoalRounds: 4,
},
roundsStarted: 0,
createdAt: 12,
updatedAt: 12,
}
appendInjection(session, renderGoalChange(change), {
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 },
meta: change as never,
})
appendInjection(session, [{ type: 'text', text: 'corrupt' }], {
source: { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 },
meta: { ...change, operation: 'edit', extra: true } as never,
})
expect(() => ctx.goals.get(agent)).toThrow('invalid shape')
expect(() => ctx.goals.get(agent)).toThrow('invalid shape')
})
})
describe('goal replay validation', () => {
function snapshotChange(overrides: Partial<GoalSnapshotChangeMeta> = {}): GoalSnapshotChangeMeta {
return {
kind: 'goal/change',
version: 1,
operation: 'create',
goal: {
id: GoalId('goal-validation'),
revision: 1,
objective: 'validate',
phase: 'active',
maxGoalRounds: 2,
},
roundsStarted: 0,
createdAt: 10,
updatedAt: 10,
...overrides,
}
}
function appendChange(
session: Session,
change: GoalChangeMeta,
overrides: { content?: ContentBlock[]; source?: MessageSource } = {},
): void {
const source = overrides.source ?? {
kind: 'goal',
goalId: change.operation === 'clear' ? change.cleared.id : change.goal.id,
revision: change.operation === 'clear' ? change.cleared.revision : change.goal.revision,
round: 0,
}
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', {
content: overrides.content ?? renderGoalChange(change),
source,
meta: change as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
function oneChange(change: GoalChangeMeta, overrides: { content?: ContentBlock[]; source?: MessageSource } = {}) {
const session = new Session(SessionId(`validation-${Math.random()}`))
appendChange(session, change, overrides)
return session.events
}
function mutation(
current: GoalSnapshotChangeMeta,
operation: Exclude<GoalSnapshotChangeMeta['operation'], 'create'>,
phase: GoalSnapshotChangeMeta['goal']['phase'],
overrides: Partial<GoalSnapshotChangeMeta> = {},
): GoalSnapshotChangeMeta {
return {
...current,
operation,
goal: {
id: current.goal.id,
revision: current.goal.revision + 1,
objective: current.goal.objective,
phase,
...phase === 'blocked'
? { blockedReason: { code: 'test-blocker', message: 'Blocked for replay validation.' } }
: {},
maxGoalRounds: current.goal.maxGoalRounds,
},
updatedAt: current.updatedAt + 1,
...overrides,
}
}
function foldPair(first: GoalSnapshotChangeMeta, second: GoalChangeMeta): ReturnType<typeof foldGoal> {
const session = new Session(SessionId(`validation-pair-${Math.random()}`))
appendChange(session, first)
appendChange(session, second)
return foldGoal(session.events)
}
it('ignores unrelated metadata and non-goal round sources', () => {
expect(decodeGoalChange(undefined)).toBeUndefined()
expect(decodeGoalChange({ kind: 'other' })).toBeUndefined()
const session = new Session(SessionId('unrelated'))
appendInjection(session, [{ type: 'text', text: 'other' }], {
source: { kind: 'plugin', plugin: 'test' },
meta: { kind: 'other' },
})
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 })
const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
session.append('user/message', { content: [{ type: 'text', text: 'ordinary' }], source }, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 })
})
it('rejects rounds attributed to another goal', () => {
const change = snapshotChange()
const session = new Session(SessionId('other-goal-round'), oneChange(change))
appendRound(session, { id: GoalId('goal-other'), revision: 1 }, 1)
expect(() => foldGoal(session.events)).toThrow('not the next admitted round')
})
it('rejects unsupported versions, operations, and top-level shapes', () => {
expect(() => decodeGoalChange({ ...snapshotChange(), version: 2 })).toThrow('unsupported goal change version')
expect(() => decodeGoalChange({ ...snapshotChange(), operation: 'explode' })).toThrow('operation is invalid')
expect(() => decodeGoalChange({ ...snapshotChange(), extra: true })).toThrow('snapshot change has an invalid shape')
expect(() => decodeGoalChange({
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'x', revision: 2 }, clearedAt: 1, extra: true,
})).toThrow('clear change has an invalid shape')
})
it('rejects invalid create and missing-current mutation sequences', () => {
const base = snapshotChange()
const invalidCreates: GoalSnapshotChangeMeta[] = [
{ ...base, goal: { ...base.goal, revision: 2 } },
{ ...base, goal: { ...base.goal, phase: 'paused' } },
{ ...base, roundsStarted: 1 },
]
for (const change of invalidCreates) expect(() => foldGoal(oneChange(change))).toThrow('goal create requires')
const edit = mutation(base, 'edit', 'active')
expect(() => foldGoal(oneChange(edit))).toThrow('requires a current goal')
const clear: GoalChangeMeta = {
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 12,
}
expect(() => foldGoal(oneChange(clear))).toThrow('clear requires a current goal')
const secondCreate = snapshotChange({
goal: { ...base.goal, id: GoalId('goal-second') },
createdAt: 20,
updatedAt: 20,
})
expect(() => foldPair(base, secondCreate)).toThrow('goal create requires')
})
it('rejects stale identity, counters, timestamps, and definition changes', () => {
const base = snapshotChange()
const invalid: GoalSnapshotChangeMeta[] = [
mutation(base, 'edit', 'active', { goal: { ...base.goal, id: GoalId('goal-wrong'), revision: 2 } }),
mutation(base, 'edit', 'active', { goal: { ...base.goal, revision: 3 } }),
mutation(base, 'edit', 'active', { createdAt: 11 }),
mutation(base, 'edit', 'active', { updatedAt: 9 }),
mutation(base, 'edit', 'active', { roundsStarted: 1 }),
mutation(base, 'pause', 'paused', {
goal: { ...base.goal, revision: 2, phase: 'paused', objective: 'changed illegally' },
}),
mutation(base, 'pause', 'paused', {
goal: { ...base.goal, revision: 2, phase: 'paused', maxGoalRounds: 3 },
}),
]
for (const change of invalid) expect(() => foldPair(base, change)).toThrow()
})
it('rejects invalid replayed lifecycle phase transitions', () => {
const base = snapshotChange()
const invalid: GoalSnapshotChangeMeta[] = [
mutation(base, 'edit', 'paused'),
mutation(base, 'pause', 'active'),
mutation(base, 'resume', 'paused'),
mutation(base, 'complete', 'active'),
mutation(base, 'block', 'active'),
]
for (const change of invalid) expect(() => foldPair(base, change)).toThrow()
const paused = mutation(base, 'pause', 'paused')
const exhausted = mutation(paused, 'resume', 'active', {
roundsStarted: 2,
goal: { ...paused.goal, revision: 3, phase: 'active', maxGoalRounds: 2 },
})
const session = new Session(SessionId('exhausted-resume'))
appendChange(session, base)
appendRound(session, base.goal, 1)
appendRound(session, base.goal, 2)
appendChange(session, { ...paused, roundsStarted: 2 })
appendChange(session, exhausted)
expect(() => foldGoal(session.events)).toThrow('exhausted round budget')
})
it('rejects invalid clear continuity and goal id reuse', () => {
const base = snapshotChange()
const staleClear: GoalChangeMeta = {
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 3 }, clearedAt: 11,
}
expect(() => foldPair(base, staleClear)).toThrow('advance the current goal')
const earlyClear: GoalChangeMeta = {
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 9,
}
expect(() => foldPair(base, earlyClear)).toThrow('timestamp cannot precede')
const complete = mutation(base, 'complete', 'complete')
const sameCurrentId = snapshotChange({
goal: { ...base.goal, revision: 1 },
createdAt: 20,
updatedAt: 20,
})
const completedSession = new Session(SessionId('reuse-complete'))
appendChange(completedSession, base)
appendChange(completedSession, complete)
appendChange(completedSession, sameCurrentId)
expect(() => foldGoal(completedSession.events)).toThrow('fresh active revision-one')
const second = snapshotChange({
goal: { ...base.goal, id: GoalId('goal-second') },
createdAt: 20,
updatedAt: 20,
})
const secondComplete = mutation(second, 'complete', 'complete')
const nonAdjacentReuse = new Session(SessionId('reuse-non-adjacent'))
appendChange(nonAdjacentReuse, base)
appendChange(nonAdjacentReuse, complete)
appendChange(nonAdjacentReuse, second)
appendChange(nonAdjacentReuse, secondComplete)
appendChange(nonAdjacentReuse, { ...sameCurrentId, createdAt: 30, updatedAt: 30 })
expect(() => foldGoal(nonAdjacentReuse.events)).toThrow('fresh active revision-one')
const clear: GoalChangeMeta = {
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 11,
}
const clearedSession = new Session(SessionId('reuse-clear'))
appendChange(clearedSession, base)
appendChange(clearedSession, clear)
appendChange(clearedSession, sameCurrentId)
expect(() => foldGoal(clearedSession.events)).toThrow('fresh active revision-one')
})
it('rejects goal-source context without matching durable metadata', () => {
const session = new Session(SessionId('goal-source-without-meta'))
const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', {
content: [{ type: 'text', text: 'missing' }], source,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
expect(() => foldGoal(session.events)).toThrow('lacks goal change metadata')
})
it('rejects malformed snapshots, refs, counters, and timestamps', () => {
const base = snapshotChange()
const badSnapshots: unknown[] = [
null,
{ ...base.goal, extra: true },
{ ...base.goal, id: '' },
{ ...base.goal, objective: ' ' },
{ ...base.goal, objective: ' padded ' },
{ ...base.goal, phase: 'unknown' },
{ ...base.goal, blockedReason: { code: 'unexpected', message: 'Only blocked goals have reasons.' } },
{ ...base.goal, phase: 'blocked' },
{ ...base.goal, phase: 'blocked', blockedReason: null },
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'test-blocker', message: 'Valid.', extra: true } },
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'NOT_CANONICAL', message: 'Bad code.' } },
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'test-blocker', message: ' padded ' } },
{ ...base.goal, revision: 0 },
{ ...base.goal, maxGoalRounds: -1 },
]
for (const goal of badSnapshots) expect(() => decodeGoalChange({ ...base, goal })).toThrow()
expect(() => decodeGoalChange({ ...base, roundsStarted: -1 })).toThrow('roundsStarted')
expect(() => decodeGoalChange({ ...base, createdAt: -1 })).toThrow('createdAt')
expect(() => decodeGoalChange({ ...base, updatedAt: 9 })).toThrow('cannot precede')
expect(() => decodeGoalChange({
kind: 'goal/change', version: 1, operation: 'clear', cleared: null, clearedAt: 1,
})).toThrow('tombstone')
expect(() => decodeGoalChange({
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: '', revision: 1 }, clearedAt: 1,
})).toThrow('non-empty')
expect(() => decodeGoalChange({
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'x', revision: 0 }, clearedAt: 1,
})).toThrow('positive safe integer')
})
it('rejects source and content drift from the durable metadata', () => {
const change = snapshotChange()
expect(() => foldGoal(oneChange(change, { source: { kind: 'plugin', plugin: 'wrong' } }))).toThrow('mismatched source')
expect(() => foldGoal(oneChange(change, {
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: -1 },
}))).toThrow('source is invalid')
expect(() => foldGoal(oneChange(change, { content: [{ type: 'text', text: 'wrong' }] }))).toThrow('model-visible content')
})
it('folds a clear tombstone after a snapshot', () => {
const change = snapshotChange()
const session = new Session(SessionId('fold-clear'), oneChange(change))
const clear: GoalChangeMeta = {
kind: 'goal/change',
version: 1,
operation: 'clear',
cleared: { id: change.goal.id, revision: 2 },
clearedAt: 20,
}
const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', {
content: renderGoalChange(clear), source, meta: clear as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
expect(foldGoal(session.events)).toEqual({
roundsStarted: 0,
lastRef: { id: change.goal.id, revision: 2 },
})
})
})
+36
View File
@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../core/scope"
},
{
"path": "../../core/agent"
}
]
}
+1
View File
@@ -30,6 +30,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
@@ -8,6 +8,7 @@
import type { Events } from 'cordis'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type {} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-goal'
import type {} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-subagent'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -43,6 +44,7 @@ const scopedSubjectResolvers = Object.freeze({
'agent/turn-continuation': adapt<'agent/turn-continuation'>(args => args[0]),
'agent/turn-stop': adapt<'agent/turn-stop'>(args => args[0]),
'approval/request': adapt<'approval/request'>(args => args[0].agent),
'goal/changed': adapt<'goal/changed'>(args => args[0]),
'session/created': null,
'session/disposed': null,
'session/event': null,
@@ -936,6 +936,7 @@ describe('scoped-dispatch invariants', () => {
['agent/turn-stop', [agent, 1]],
['agent/error', [agent, 1, 0, new Error('x')]],
['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]],
['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]],
['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]],
['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
@@ -23,6 +23,9 @@
{
"path": "../../core/agent"
},
{
"path": "../../goal/goal"
},
{
"path": "../../core/scope"
},
+34
View File
@@ -134,6 +134,9 @@ importers:
'@deepseek-ai/dsh-fs-sandbox':
specifier: workspace:^
version: link:../packages/fs/fs-sandbox
'@deepseek-ai/dsh-goal':
specifier: workspace:*
version: link:../packages/goal/goal
'@deepseek-ai/dsh-hooks-claude':
specifier: workspace:*
version: link:../packages/hooks/hooks-claude
@@ -1058,6 +1061,34 @@ importers:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/goal/goal:
dependencies:
schemastery:
specifier: ^3.17.2
version: 3.18.0
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-brand':
specifier: workspace:^
version: link:../../util/brand
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-loader-smoke':
specifier: workspace:^
version: link:../../support/loader-smoke
'@deepseek-ai/dsh-scope':
specifier: workspace:^
version: link:../../core/scope
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/guard/repeat-tool-guard:
dependencies:
schemastery:
@@ -1912,6 +1943,9 @@ importers:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-goal':
specifier: workspace:^
version: link:../../goal/goal
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
+6
View File
@@ -71,6 +71,12 @@ export const LINK_MAP: Record<string, string> = {
FsVersion: 'filesystem.md',
FsWriteIntent: 'filesystem.md',
FsWriteOutcome: 'filesystem.md',
CreateGoalRequest: 'goal.md',
EditGoalRequest: 'goal.md',
GoalBlockReason: 'goal.md',
GoalChanged: 'goal.md',
GoalRef: 'goal.md',
GoalView: 'goal.md',
LlmAdapter: 'llm-streaming.md',
LlmService: 'llm-streaming.md',
StreamChunk: 'llm-streaming.md',
+8
View File
@@ -58,6 +58,7 @@ const GROUP_ORDER = [
'util',
'llm',
'core',
'goal',
'bash',
'sandbox',
'fs',
@@ -177,6 +178,13 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['agent-spine-demo'],
note: 'The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package.',
},
{
key: 'goals',
pkg: 'goal',
title: 'Same-session goal domain',
mode: 'core',
note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.',
},
{
key: 'bash',
pkg: 'bash',
+1
View File
@@ -21,6 +21,7 @@ const GROUP_ORDER = [
'util',
'llm',
'core',
'goal',
'bash',
'fs',
'skill',
+12
View File
@@ -27,6 +27,18 @@
{ "doc": "docs/core-data-structures/scope.md", "symbol": "Scoped", "source": "packages/core/scope/src/index.ts" },
{ "doc": "docs/core-data-structures/scope.md", "symbol": "Scope", "source": "packages/core/scope/src/index.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalRef", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalPhase", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalBlockReason", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalSnapshot", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalView", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalSnapshotChangeMeta", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalClearChangeMeta", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalMessageSource", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "CreateGoalRequest", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalChanged", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/system-prompt.md", "symbol": "AssembleContext", "source": "packages/core/system-prompt/src/index.ts" },
{ "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptSection", "source": "packages/core/system-prompt/src/index.ts" },
{ "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" },
+1
View File
@@ -49,6 +49,7 @@
"./packages/skill/*/src",
"./packages/compact/*/src",
"./packages/context/*/src",
"./packages/goal/*/src",
"./packages/guard/*/src",
"./packages/subagent/*/src",
"./packages/tasks/*/src",
+1
View File
@@ -25,6 +25,7 @@
{ "path": "./packages/session-query/session-query" },
{ "path": "./packages/core/system-prompt" },
{ "path": "./packages/core/agent" },
{ "path": "./packages/goal/goal" },
{ "path": "./packages/context/time-context" },
{ "path": "./packages/ui/user-interaction" },
{ "path": "./packages/ui/user-approval" },
+1
View File
@@ -38,6 +38,7 @@
{ "path": "./packages/session-query/session-query" },
{ "path": "./packages/core/system-prompt" },
{ "path": "./packages/core/agent" },
{ "path": "./packages/goal/goal" },
{ "path": "./packages/context/time-context" },
{ "path": "./packages/ui/user-interaction" },
{ "path": "./packages/ui/user-approval" },
+182
View File
@@ -0,0 +1,182 @@
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
# ctx.goals
`GoalService` — provided by `@deepseek-ai/dsh-goal`.
Goal service (`ctx.goals`) backed exclusively by the owning session log.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L135)
### ctx.goals.get(agent)
```ts website-api
/**
* Read the current goal for one exact live agent.
* @param agent - owning live agent.
* @returns a fresh view or `undefined` when no goal is current.
* @throws {@link GoalError} when the agent is not the registry's live instance.
*/
get(agent: Agent): GoalView | undefined
```
Read the current goal for one exact live agent.
- `agent` — owning live agent.
**Returns** a fresh view or `undefined` when no goal is current.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L161)
### ctx.goals.create(agent, request)
```ts website-api
/**
* Create and arm a goal. A completed goal may be replaced; every other
* current phase must be cleared or resumed instead.
* @param agent - owning live agent.
* @param request - objective and optional round cap.
* @returns the created live view.
*/
create(agent: Agent, request: CreateGoalRequest): GoalView
```
Create and arm a goal. A completed goal may be replaced; every other current phase must be cleared or resumed instead.
- `agent` — owning live agent.
- `request` — objective and optional round cap.
**Returns** the created live view.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L175)
### ctx.goals.edit(agent, ref, request)
```ts website-api
/**
* Edit objective and/or round cap without changing phase.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @param request - at least one replacement field.
* @returns the edited view.
*/
edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView
```
Edit objective and/or round cap without changing phase.
- `agent` — owning live agent.
- `ref` — expected current revision.
- `request` — at least one replacement field.
**Returns** the edited view.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L200)
### ctx.goals.pause(agent, ref)
```ts website-api
/**
* Pause an active goal and disarm automatic continuation.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the paused view.
*/
pause(agent: Agent, ref: GoalRef): GoalView
```
Pause an active goal and disarm automatic continuation.
- `agent` — owning live agent.
- `ref` — expected current revision.
**Returns** the paused view.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L221)
### ctx.goals.resume(agent, ref)
```ts website-api
/**
* Resume and arm a stopped goal, or rearm an active goal after a
* session-start edge, while its round budget still has capacity.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the active view.
*/
resume(agent: Agent, ref: GoalRef): GoalView
```
Resume and arm a stopped goal, or rearm an active goal after a session-start edge, while its round budget still has capacity.
- `agent` — owning live agent.
- `ref` — expected current revision.
**Returns** the active view.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L232)
### ctx.goals.complete(agent, ref)
```ts website-api
/**
* Mark a current non-complete goal complete and disarm it.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the completed view.
*/
complete(agent: Agent, ref: GoalRef): GoalView
```
Mark a current non-complete goal complete and disarm it.
- `agent` — owning live agent.
- `ref` — expected current revision.
**Returns** the completed view.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L257)
### ctx.goals.block(agent, ref, reason)
```ts website-api
/**
* Mark an active goal blocked and disarm it.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @param reason - policy-owned stable code and human-readable explanation.
* @returns the blocked view with its durable reason.
*/
block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView
```
Mark an active goal blocked and disarm it.
- `agent` — owning live agent.
- `ref` — expected current revision.
- `reason` — policy-owned stable code and human-readable explanation.
**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)
### ctx.goals.clear(agent, ref)
```ts website-api
/**
* Clear the current goal while retaining a durable tombstone and history.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the tombstone ref whose revision is one past the cleared snapshot.
*/
clear(agent: Agent, ref: GoalRef): GoalRef
```
Clear the current goal while retaining a durable tombstone and history.
- `agent` — owning live agent.
- `ref` — expected current revision.
**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)