From a525776015c10c1a5c25c3cc625bb4a55f4f811a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:47:34 +0800 Subject: [PATCH] feat(goal): add persisted same-session goal domain --- docs/architecture.md | 10 +- docs/capability-seams.md | 4 + docs/config-catalog.md | 14 + docs/cordis-catalog/events.md | 23 + docs/cordis-catalog/services.md | 100 +++ docs/core-data-structures/core.md | 1 + docs/core-data-structures/goal.md | 139 ++++ docs/event-producer-consumer.md | 3 +- docs/glossary.md | 12 + docs/module-graph.md | 9 + docs/rfc/INDEX.md | 1 + ...rsisted-same-session-goal-domain.i18n.yaml | 6 + ...7-19-persisted-same-session-goal-domain.md | 62 ++ ...9-persisted-same-session-goal-domain.zh.md | 62 ++ .../tests/fixtures/goal/goal/cordis.yml | 24 + .../tests/fixtures/goal/goal/seed-goal.ts | 16 + examples/package.json | 1 + knip.json | 5 + packages/README.md | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 93 +++ packages/goal/README.md | 9 + packages/goal/goal/README.md | 45 ++ packages/goal/goal/package.json | 44 + packages/goal/goal/src/fold.ts | 379 +++++++++ packages/goal/goal/src/index.ts | 505 ++++++++++++ packages/goal/goal/src/render.ts | 21 + packages/goal/goal/src/runtime.ts | 27 + packages/goal/goal/src/types.ts | 168 ++++ packages/goal/goal/tests/goal.e2e.ts | 126 +++ packages/goal/goal/tests/goal.spec.ts | 749 ++++++++++++++++++ packages/goal/goal/tsconfig.json | 36 + packages/support/invariants/package.json | 1 + .../invariants/src/scoped-events.generated.ts | 2 + .../invariants/tests/invariants.spec.ts | 1 + packages/support/invariants/tsconfig.json | 3 + pnpm-lock.yaml | 34 + scripts/gen-cordis-catalog.ts | 6 + scripts/gen-doc-graphs.ts | 8 + scripts/gen-module-graph.ts | 1 + scripts/type-equiv.manifest.json | 12 + tsconfig.base.json | 1 + tsconfig.build.json | 1 + tsconfig.json | 1 + website/.vitepress/config/api-sidebar.json | 4 + website/zh-CN/api/harness/events.md | 28 +- website/zh-CN/api/harness/goals.md | 241 ++++++ 46 files changed, 3033 insertions(+), 6 deletions(-) create mode 100644 docs/core-data-structures/goal.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md create mode 100644 examples/echo-agent/tests/fixtures/goal/goal/cordis.yml create mode 100644 examples/echo-agent/tests/fixtures/goal/goal/seed-goal.ts create mode 100644 packages/goal/README.md create mode 100644 packages/goal/goal/README.md create mode 100644 packages/goal/goal/package.json create mode 100644 packages/goal/goal/src/fold.ts create mode 100644 packages/goal/goal/src/index.ts create mode 100644 packages/goal/goal/src/render.ts create mode 100644 packages/goal/goal/src/runtime.ts create mode 100644 packages/goal/goal/src/types.ts create mode 100644 packages/goal/goal/tests/goal.e2e.ts create mode 100644 packages/goal/goal/tests/goal.spec.ts create mode 100644 packages/goal/goal/tsconfig.json create mode 100644 website/zh-CN/api/harness/goals.md diff --git a/docs/architecture.md b/docs/architecture.md index fd21648dd2..2c6cd9c9ea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # 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 @@ -35,6 +35,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 | @@ -54,9 +55,9 @@ Waterfall events behave like around-middleware: a listener delegates by calling ## Default Loop Lifecycle -The shipped loop drains work from prompt through checkpoint. Every pause is a service call or event available to plugins. +The shipped loop drains prompts through checkpoints; every pause is exposed to plugins through services or events. -A **session** is an append-only event log. A **turn** drains queued input until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. +A **turn** drains queued input in an append-only session until the model requests no more tools or plugin continuation. A **step** is one model request plus its tool executions. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. Startup resolves identity. No id mints `-session-`; `sessionId` resumes or creates; `resumeSessionId` requires history. Active failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject work; teardown stays silent. @@ -108,7 +109,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 RFC](rfc/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 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, remains authoritative through turn close and flush, and discards later steering while preserving queued prompts. `dsh-compact-basic` handles pressure and canonical overflow at these checkpoints; retry requires a balanced surface replacement ([RFC](rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)). @@ -174,6 +175,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 | call `ctx.goals`; drive continuation through `Agent` and `agent/*` seams | | 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) | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 58641456b1..64ddcc40c5 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -52,6 +52,8 @@ flowchart LR svc_agents["ctx.agents
Agent service"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_agent_spine_demo["agent-spine-demo"] + pkg_goal["goal"] + svc_goals["ctx.goals
Same-session goal domain"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] pkg_bash_local["bash-local"] @@ -109,6 +111,7 @@ flowchart LR pkg_compact_basic --> svc_compact pkg_fs --> svc_fs pkg_fs_local --> svc_fs + pkg_goal --> svc_goals pkg_llm --> svc_llm pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm @@ -217,6 +220,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), [`stdio-demo`](../packages/examples/stdio-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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 225098e851..6473848ab3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -318,6 +318,20 @@ export interface Config { Source: [`packages/fs/fs-local/src/index.ts:38`](../packages/fs/fs-local/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` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 7d7472a33e..0f5a4650cd 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -450,6 +450,29 @@ Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../c Source: [`packages/fs/fs/src/index.ts:53`](../../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, 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:166`](../../packages/goal/goal/src/types.ts) + ## `llm/*` ### `llm/stream` — waterfall diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 10cd0bbf05..934086dd97 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -479,6 +479,106 @@ Types: [FsDirEntry](../core-data-structures/filesystem.md) · [FsEditOutcome](.. Source: [`packages/fs/fs/src/index.ts:80`](../../packages/fs/fs/src/index.ts) +## `ctx.goals` — `GoalService` + +Goal service (`ctx.goals`) backed exclusively by the owning session log. + +```ts cordis-catalog +/** + * Materialize deployment defaults and validate one create request. + * @param request - objective plus optional caller-selected round cap. + * @returns detached, fully resolved create specification. + */ +resolveCreate(request: CreateGoalRequest): CreateGoalSpec + +/** + * 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. + * @returns the blocked view. + */ +block(agent: Agent, ref: GoalRef): GoalView + +/** + * Mark an active goal stopped by an external usage limit. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the usage-limited view. + */ +markUsageLimited(agent: Agent, ref: GoalRef): GoalView + +/** + * Mark an active goal stopped at its configured round cap. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the budget-limited view. + */ +markBudgetLimited(agent: Agent, ref: GoalRef): 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) · [CreateGoalSpec](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) + +Source: [`packages/goal/goal/src/index.ts:97`](../../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. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 1d45f3814a..25ded90954 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -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 | diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md new file mode 100644 index 0000000000..76a1d0ed71 --- /dev/null +++ b/docs/core-data-structures/goal.md @@ -0,0 +1,139 @@ +# Same-session goals + +Types shared by the event-sourced goal domain and its policy consumers. The [goal-domain RFC](../rfc/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' + | 'usage-limited' + | 'budget-limited' + | 'complete' +``` + +```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 + /** 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, raw envelope, and 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 + 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 resolved deployment choice. 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 +/** Validated create input with every deployment default materialized. */ +interface CreateGoalSpec { + 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. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4f6e6daff0..8f1fd333fe 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | @@ -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:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../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:166`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | - | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../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) | diff --git a/docs/glossary.md b/docs/glossary.md index 81b9b4f84a..74dc5e14d5 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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`, `subagentDepth`); never affects visibility. + +## goal + +- **goal** — one durable completion objective attached to an existing session, with a revisioned lifecycle phase and a goal-round cap. A goal is state, not a scheduler or a separate conversation; the session log remains its source of truth. +- **goal round** — one continuation cycle admitted for the current goal. The same-session driver materializes a goal round as one goal-sourced [turn](#turn), which can contain multiple steps; unrelated human turns in the same session do not consume the goal-round cap. +- **goal activation** — process-local permission for a continuation consumer to admit another goal round. Activation is either `armed` or `disarmed`; it is deliberately absent from durable replay, so resume and fork require a later explicit resume mutation before automatic work. + +## loop hierarchy + +- **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. +- **step** — one model request plus the tool executions caused by its response; a turn contains one or more steps. +- **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round) or one fresh-agent Ralph attempt. Round counters belong to that policy and do not count every turn in a session. diff --git a/docs/module-graph.md b/docs/module-graph.md index 0886877f0e..56934f8495 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -28,6 +28,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"] @@ -194,6 +197,11 @@ flowchart TD pkg_llm_replay --> pkg_session pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox + 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_compact_basic --> pkg_agent @@ -485,6 +493,7 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`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) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index bca5cf61f9..e6d4ef306b 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -89,6 +89,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 | | [Durable per-step time context](implemented/feature/2026-07-16-durable-per-step-time-context.md) | 2026-07-16 | | [Dedicated full-screen TUI front door](implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) | 2026-07-17 | +| [Persisted same-session goal domain](implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) | 2026-07-19 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml new file mode 100644 index 0000000000..771afcf5bf --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-persisted-same-session-goal-domain.md: ebf6168a4d8552d40e22db309f953f6f273511a9 +2026-07-19-persisted-same-session-goal-domain.zh.md: 5fd35ee8faffc1c53d257bee48f36d70838fdee1 diff --git a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md new file mode 100644 index 0000000000..ebf6168a4d --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md @@ -0,0 +1,62 @@ +# RFC: 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`; `resolveCreate()` materializes it before mutation. + +The durable phases are `active`, `paused`, `blocked`, `usage-limited`, `budget-limited`, and `complete`. A separate live activation is `armed` or `disarmed`. Creation and explicit resume arm activation; pause, completion, blocking, limit transitions, and clear disarm it. Edits preserve activation. Activation is never part of the persisted snapshot. + +### Durable record and replay + +Every non-clear mutation uses `Agent.inject()` to append a raw, model-visible `context/message` containing a versioned full snapshot. Clear appends a revisioned tombstone. The context source is `{ kind: 'goal', goalId, revision, round: 0 }`; metadata and rendered `...` content must agree exactly. 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; 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 stopped phase or a disarmed active goal only when the round cap has remaining capacity; budget limiting requires the admitted count to have reached the cap. + +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, cap enforcement, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start disarming and active-goal rearming, FIFO deferred mutation reconciliation, 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; token, currency, time, and provider limits remain separate policy concerns. + +## 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. +- `GOAL_CHANGE_VERSION` has no pre-release compatibility promise or migration path. diff --git a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md new file mode 100644 index 0000000000..5fd35ee8fa --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md @@ -0,0 +1,62 @@ +# RFC: 持久的同会话目标领域 + +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`;`resolveCreate()` 在变更前将其解析为完整值。 + +持久阶段包括 `active`、`paused`、`blocked`、`usage-limited`、`budget-limited` 和 `complete`。独立的实时激活态为 `armed` 或 `disarmed`。创建与显式恢复会激活目标;暂停、完成、阻塞、达到限制和清除都会解除激活。编辑保留激活态。持久快照绝不包含激活态。 + +### 持久记录与回放 + +每次非清除变更都通过 `Agent.inject()` 追加一条原始且模型可见的 `context/message`,其中包含带版本的完整快照。清除操作追加带修订号的墓碑。上下文来源为 `{ kind: 'goal', goalId, revision, round: 0 }`;元数据必须与渲染后的 `...` 内容完全一致。会话日志是唯一的持久事实来源,因此持久化和 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` 在首次发布前不承诺兼容性,也不提供迁移路径。 diff --git a/examples/echo-agent/tests/fixtures/goal/goal/cordis.yml b/examples/echo-agent/tests/fixtures/goal/goal/cordis.yml new file mode 100644 index 0000000000..9d9e644cfa --- /dev/null +++ b/examples/echo-agent/tests/fixtures/goal/goal/cordis.yml @@ -0,0 +1,24 @@ +# Test-only composition: create one goal through a Loader-mounted lifecycle consumer. +- id: mock-llm + name: '../../../../src/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: stdio-agent + name: '@deepseek-ai/dsh-stdio-demo' + config: + provider: mock + model: mock-echo + persona: 'Test the persisted goal domain.' + welcome: 'goal-domain e2e ready.' + persistenceRoot: './.sessions' + workspaceContext: false diff --git a/examples/echo-agent/tests/fixtures/goal/goal/seed-goal.ts b/examples/echo-agent/tests/fixtures/goal/goal/seed-goal.ts new file mode 100644 index 0000000000..ae3d0d231c --- /dev/null +++ b/examples/echo-agent/tests/fixtures/goal/goal/seed-goal.ts @@ -0,0 +1,16 @@ +/** Test-only Loader plugin that creates a goal at the real session-start 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/session-start', (agent) => { + ctx.goals.create(agent, { + objective: 'Prove the composed goal survives in the session log', + maxGoalRounds: 7, + }) + }) +} diff --git a/examples/package.json b/examples/package.json index 76349b2ea4..3a3815582a 100644 --- a/examples/package.json +++ b/examples/package.json @@ -15,6 +15,7 @@ "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-policy": "workspace:*", + "@deepseek-ai/dsh-goal": "workspace:*", "@deepseek-ai/dsh-hooks-claude": "workspace:*", "@deepseek-ai/dsh-hooks-codex": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", diff --git a/knip.json b/knip.json index 2c3465ab38..6f04363494 100644 --- a/knip.json +++ b/knip.json @@ -10,6 +10,7 @@ "examples": { "entry": [ "echo-agent/src/*.ts", + "echo-agent/tests/fixtures/goal/goal/seed-goal.ts", "headless-agent/tests/fixtures/cli-mock-llm.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", @@ -66,6 +67,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"] diff --git a/packages/README.md b/packages/README.md index 61a1e994c8..f2298a47fa 100644 --- a/packages/README.md +++ b/packages/README.md @@ -9,6 +9,7 @@ Packages live at `packages///`; 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 | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 52f8477318..9262046683 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -250,6 +250,56 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'goals', + summary: 'Goal service (`ctx.goals`) backed exclusively by the owning session log.', + methods: [ + { + signature: 'resolveCreate(request: CreateGoalRequest): CreateGoalSpec', + jsDoc: '/**\n * Materialize deployment defaults and validate one create request.\n * @param request - objective plus optional caller-selected round cap.\n * @returns detached, fully resolved create specification.\n */', + }, + { + 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): GoalView', + jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the blocked view.\n */', + }, + { + signature: 'markUsageLimited(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Mark an active goal stopped by an external usage limit.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the usage-limited view.\n */', + }, + { + signature: 'markBudgetLimited(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Mark an active goal stopped at its configured round cap.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the budget-limited view.\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.', @@ -746,6 +796,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, 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', @@ -1076,6 +1133,14 @@ 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 };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, + { + name: 'CreateGoalRequest', + declaration: 'export interface CreateGoalRequest {\n readonly objective: string;\n readonly maxGoalRounds?: number;\n}', + }, + { + name: 'CreateGoalSpec', + declaration: 'export interface CreateGoalSpec {\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 };\n}', @@ -1096,6 +1161,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}', @@ -1168,6 +1237,30 @@ 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: 'GoalId', + declaration: 'export type GoalId = Branded<\'GoalId\'>;', + }, + { + name: 'GoalPhase', + declaration: 'export type GoalPhase = \'active\' | \'paused\' | \'blocked\' | \'usage-limited\' | \'budget-limited\' | \'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 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 envelope?: ContextEnvelope;\n meta?: JsonValue;\n}', diff --git a/packages/goal/README.md b/packages/goal/README.md new file mode 100644 index 0000000000..45f1b4f135 --- /dev/null +++ b/packages/goal/README.md @@ -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. diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md new file mode 100644 index 0000000000..57f366c844 --- /dev/null +++ b/packages/goal/goal/README.md @@ -0,0 +1,45 @@ +# @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 RFC](../../../docs/rfc/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. `resolveCreate()` materializes this deployment default before `create()` commits 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, usage-limit, budget-limit, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). + +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 and activation. Pause, completion, blocking, limit transitions, and clear disarm activation. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; 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 raw `context/message`, 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. `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":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. + +**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. + +## 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. diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json new file mode 100644 index 0000000000..534c258c6b --- /dev/null +++ b/packages/goal/goal/package.json @@ -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" + } +} diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts new file mode 100644 index 0000000000..d7256a38db --- /dev/null +++ b/packages/goal/goal/src/fold.ts @@ -0,0 +1,379 @@ +/** 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, + GoalChangeMeta, + GoalClearChangeMeta, + GoalMessageSource, + GoalOperation, + GoalPhase, + GoalRef, + GoalSnapshot, + GoalSnapshotChangeMeta, +} from './types.ts' + +type ContextMessageEvent = Extract + +const SNAPSHOT_OPERATIONS: ReadonlySet> = new Set([ + 'create', + 'edit', + 'pause', + 'resume', + 'complete', + 'block', + 'mark-usage-limited', + 'mark-budget-limited', +]) +const PHASES: ReadonlySet = new Set([ + 'active', + 'paused', + 'blocked', + 'usage-limited', + 'budget-limited', + '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 +} + +/** + * 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 { + 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 and validate one snapshot. */ +function decodeSnapshot(value: unknown): GoalSnapshot { + if (!isRecord(value)) throw new Error('goal change goal must be a record') + const keys = Object.keys(value).sort() + if (keys.join(',') !== 'id,maxGoalRounds,objective,phase,revision') { + throw new Error('goal change goal has an invalid shape') + } + 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') + } + return { + id: GoalId(value['id']), + revision: positiveInteger(value['revision'], 'goal.revision'), + objective: value['objective'], + phase: value['phase'] as GoalPhase, + maxGoalRounds: positiveInteger(value['maxGoalRounds'], 'goal.maxGoalRounds'), + } +} + +/** 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)) { + 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, + 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) throw new Error('goal edit cannot change phase') + 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 = new Set([ + 'active', + 'paused', + 'blocked', + 'usage-limited', + 'budget-limited', + ]) + 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 + case 'mark-usage-limited': + requireSameDefinition(current, next, change.operation) + if (current.phase !== 'active' || next.phase !== 'usage-limited') { + throw new Error('goal mark-usage-limited has an invalid phase transition') + } + break + case 'mark-budget-limited': + requireSameDefinition(current, next, change.operation) + if (current.phase !== 'active' || next.phase !== 'budget-limited' + || state.roundsStarted < next.maxGoalRounds) { + throw new Error('goal mark-budget-limited has an invalid phase transition or remaining round budget') + } + 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 (event.data.envelope !== 'raw' + || 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 } }, + } +} diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts new file mode 100644 index 0000000000..726ceb6c53 --- /dev/null +++ b/packages/goal/goal/src/index.ts @@ -0,0 +1,505 @@ +/** + * 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, + CreateGoalSpec, + EditGoalRequest, + GoalActivation, + 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 +} + +/** Process-local cache plus mutations waiting in the active tool-batch FIFO. */ +interface GoalCache { + readonly state: GoalFoldState + activation: GoalActivation + observedSeq: number + readonly pending: GoalChangeMeta[] +} + +/** 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() +} + +/** 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 = z.object({ + defaultMaxGoalRounds: z.number().default(256), + }) + + private readonly resolved: ResolvedConfig + private readonly caches = new WeakMap() + + 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' + }) + } + + /** + * Materialize deployment defaults and validate one create request. + * @param request - objective plus optional caller-selected round cap. + * @returns detached, fully resolved create specification. + */ + resolveCreate(request: CreateGoalRequest): CreateGoalSpec { + return { + objective: resolveObjective(request.objective), + maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? this.resolved.defaultMaxGoalRounds), + } + } + + /** + * 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 = this.resolveCreate(request) + 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', 'usage-limited', 'budget-limited'] + 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', 'usage-limited', 'budget-limited'], + 'complete', + 'disarmed', + ) + } + + /** + * Mark an active goal blocked and disarm it. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the blocked view. + */ + block(agent: Agent, ref: GoalRef): GoalView { + return this.transition(agent, ref, 'block', ['active'], 'blocked', 'disarmed') + } + + /** + * Mark an active goal stopped by an external usage limit. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the usage-limited view. + */ + markUsageLimited(agent: Agent, ref: GoalRef): GoalView { + return this.transition(agent, ref, 'mark-usage-limited', ['active'], 'usage-limited', 'disarmed') + } + + /** + * Mark an active goal stopped at its configured round cap. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the budget-limited view. + */ + markBudgetLimited(agent: Agent, ref: GoalRef): GoalView { + const cache = this.prepareMutation(agent) + const current = this.expectCurrent(cache, ref) + if (current.phase !== 'active') { + throw this.transitionError(current, 'mark-budget-limited', ['active']) + } + if (cache.state.roundsStarted < current.maxGoalRounds) { + throw new GoalError( + `goal "${current.id}" has started ${cache.state.roundsStarted}/${current.maxGoalRounds} rounds`, + 'GOAL_INVALID_TRANSITION', + ) + } + return this.commitCurrent( + agent, + cache, + 'mark-budget-limited', + this.withPhase(current, 'budget-limited'), + '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)) { + cache.pending.shift() + continue + } + } + } + applyGoalEvent(cache.state, event) + } + cache.observedSeq = session.seq + } + + /** Build a new revision with one replacement phase. */ + private withPhase(current: GoalSnapshot, phase: GoalPhase): GoalSnapshot { + return { ...current, revision: current.revision + 1, phase } + } + + /** Shared validated phase transition. */ + private transition( + agent: Agent, + ref: GoalRef, + operation: Exclude, + 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, + 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, + 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') + agent.inject(renderGoalChange(change), { + source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0 }, + envelope: 'raw', + meta, + }) + cache.pending.push(change) + applyGoalChange(cache.state, change) + cache.activation = activation + 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 diff --git a/packages/goal/goal/src/render.ts b/packages/goal/goal/src/render.ts new file mode 100644 index 0000000000..f84a1b51ea --- /dev/null +++ b/packages/goal/goal/src/render.ts @@ -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 raw context block logged 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: `${JSON.stringify(payload)}` }] +} diff --git a/packages/goal/goal/src/runtime.ts b/packages/goal/goal/src/runtime.ts new file mode 100644 index 0000000000..9cd656ccae --- /dev/null +++ b/packages/goal/goal/src/runtime.ts @@ -0,0 +1,27 @@ +/** Runtime constructors and protocol constants for the goal domain. */ + +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 Error { + /** + * @param message - human-readable rejection reason. + * @param code - stable machine-routable classification. + */ + constructor(message: string, public readonly code: GoalErrorCode) { + super(message) + this.name = 'GoalError' + } +} diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts new file mode 100644 index 0000000000..ef40e997ef --- /dev/null +++ b/packages/goal/goal/src/types.ts @@ -0,0 +1,168 @@ +/** + * 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' + | 'usage-limited' + | 'budget-limited' + | 'complete' + +/** 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 + /** 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' + | 'mark-usage-limited' + | 'mark-budget-limited' + | '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 + 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 +} + +/** Validated create input with every deployment default materialized. */ +export interface CreateGoalSpec { + 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_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, change: GoalChanged): void + } +} diff --git a/packages/goal/goal/tests/goal.e2e.ts b/packages/goal/goal/tests/goal.e2e.ts new file mode 100644 index 0000000000..0942248b78 --- /dev/null +++ b/packages/goal/goal/tests/goal.e2e.ts @@ -0,0 +1,126 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { decodeGoalChange, renderGoalChange } from '@deepseek-ai/dsh-goal' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + +const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL( + '../../../../examples/echo-agent/tests/fixtures/goal/goal/cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) +const PROCESS_TIMEOUT_MS = 30_000 +const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 +const REPLY = 'You said: "hello". Try "echo " to see a tool call.' + +let child: ChildProcessWithoutNullStreams | undefined +let workdir: string | undefined + +afterEach(async () => { + if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') + child = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +async function runOneTurn(): Promise<{ stdout: string; stderr: string }> { + workdir = await mkdtemp(join(tmpdir(), 'goal-domain-e2e-')) + const cwd = workdir + return new Promise((resolve, reject) => { + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: [configPath], + tsconfigPath: repoTsconfig, + exposeInternals: true, + env: { + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + }) + const proc = spawn(launch.command, launch.args, { + cwd, + env: { ...process.env, ...launch.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + child = proc + let stdout = '' + let stderr = '' + let inputClosed = false + proc.stdout.setEncoding('utf8') + proc.stdout.on('data', (chunk: string) => { + stdout += chunk + if (!inputClosed && stdout.includes(REPLY)) { + inputClosed = true + proc.stdin.end() + } + }) + proc.stderr.setEncoding('utf8') + proc.stderr.on('data', (chunk: string) => { stderr += chunk }) + + const timer = setTimeout(() => { + proc.kill('SIGKILL') + reject(new Error(`goal-domain e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, PROCESS_TIMEOUT_MS) + + proc.on('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve({ stdout, stderr }) + else reject(new Error(`goal-domain e2e exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + proc.on('error', (error) => { clearTimeout(timer); reject(error) }) + proc.stdin.write('hello\n') + }) +} + +describe('goal domain through a real cordis.yml and stdio process', () => { + it('persists the Loader-created snapshot without starting a goal round', async () => { + const { stdout, stderr } = await runOneTurn() + expect(stderr).not.toContain('UNHANDLED') + expect(stdout).toContain('goal-domain e2e ready.') + expect(stdout).toContain(REPLY) + + const logs = await jsonlFiles(join(workdir as string, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) + expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) + + 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.envelope).toBe('raw') + 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) + }, TEST_TIMEOUT_MS) +}) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts new file mode 100644 index 0000000000..03b00dd699 --- /dev/null +++ b/packages/goal/goal/tests/goal.spec.ts @@ -0,0 +1,749 @@ +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 type { ContentBlock, 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?.envelope === undefined ? {} : { envelope: options.envelope }, + ...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('resolves the configured default and writes one balanced raw 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) }) + + expect(ctx.goals.resolveCreate({ objective: ' finish the feature ' })).toEqual({ + objective: 'finish the feature', + maxGoalRounds: 17, + }) + 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.envelope).toBe('raw') + 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 at the owning resolver', async () => { + const { ctx, agent } = await harness() + expect(ctx.goals.resolveCreate({ objective: 'x' })).toEqual({ objective: 'x', maxGoalRounds: 256 }) + expect(() => ctx.goals.resolveCreate({ objective: ' ' })).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_OBJECTIVE', + })) + expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 0 })).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_MAX_ROUNDS', + })) + expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError) + expect(() => ctx.goals.resolveCreate({ 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) + expect(goals.resolveCreate({ objective: 'direct' })).toEqual({ + 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('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, usage-limit, 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) + expect(goal).toMatchObject({ phase: 'blocked', activation: 'disarmed' }) + goal = ctx.goals.resume(agent, goal) + goal = ctx.goals.markUsageLimited(agent, goal) + expect(goal.phase).toBe('usage-limited') + 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', 'usage-limited'] 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) + : phase === 'blocked' + ? ctx.goals.block(agent, goal) + : ctx.goals.markUsageLimited(agent, goal) + 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)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) + expect(() => ctx.goals.markUsageLimited(agent, paused)).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_TRANSITION', + })) + expect(() => ctx.goals.markBudgetLimited(agent, paused)).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_TRANSITION', + })) + }) + + it('enforces the goal-round cap before budget limiting and resuming', async () => { + const { ctx, agent, session } = await harness() + let goal = ctx.goals.create(agent, { objective: 'bounded', maxGoalRounds: 2 }) + appendRound(session, goal, 1) + expect(ctx.goals.get(agent)?.roundsStarted).toBe(1) + expect(() => ctx.goals.markBudgetLimited(agent, goal)).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_TRANSITION', + })) + appendRound(session, goal, 2) + goal = ctx.goals.markBudgetLimited(agent, goal) + expect(goal).toMatchObject({ phase: 'budget-limited', 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 }) + goal = ctx.goals.resume(agent, goal) + expect(goal).toMatchObject({ phase: 'active', maxGoalRounds: 3, activation: 'armed' }) + appendRound(session, goal, 3) + goal = ctx.goals.markBudgetLimited(agent, goal) + 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('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, envelope: 'raw', 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', + }) + }) +}) + +describe('goal replay validation', () => { + function snapshotChange(overrides: Partial = {}): 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; envelope?: 'raw' } = {}, + ): 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, + envelope: overrides.envelope ?? 'raw', + meta: change as never, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + + function oneChange(change: GoalChangeMeta, overrides: { content?: ContentBlock[]; source?: MessageSource; envelope?: 'raw' } = {}) { + const session = new Session(SessionId(`validation-${Math.random()}`)) + appendChange(session, change, overrides) + return session.events + } + + function mutation( + current: GoalSnapshotChangeMeta, + operation: Exclude, + phase: GoalSnapshotChangeMeta['goal']['phase'], + overrides: Partial = {}, + ): GoalSnapshotChangeMeta { + return { + ...current, + operation, + goal: { ...current.goal, revision: current.goal.revision + 1, phase }, + updatedAt: current.updatedAt + 1, + ...overrides, + } + } + + function foldPair(first: GoalSnapshotChangeMeta, second: GoalChangeMeta): ReturnType { + 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'), + mutation(base, 'mark-usage-limited', 'active'), + mutation(base, 'mark-budget-limited', 'active'), + mutation(base, 'mark-budget-limited', 'budget-limited'), + ] + 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, envelope: 'raw', + }, { 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, 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, content, and envelope 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') + const events = oneChange(change) + const context = events.find(event => event.type === 'context/message') + if (context?.type !== 'context/message') throw new Error('expected context') + const altered = structuredClone(events) + const clonedContext = altered.find(event => event.type === 'context/message') + if (clonedContext?.type !== 'context/message') throw new Error('expected cloned context') + delete (clonedContext.data as { envelope?: string }).envelope + expect(() => foldGoal(altered)).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, envelope: 'raw', 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 }, + }) + }) +}) diff --git a/packages/goal/goal/tsconfig.json b/packages/goal/goal/tsconfig.json new file mode 100644 index 0000000000..8a97b2a5e0 --- /dev/null +++ b/packages/goal/goal/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index 59a425387b..9ca1e0aab1 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -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:^", diff --git a/packages/support/invariants/src/scoped-events.generated.ts b/packages/support/invariants/src/scoped-events.generated.ts index 36d1721945..3c112d59a8 100644 --- a/packages/support/invariants/src/scoped-events.generated.ts +++ b/packages/support/invariants/src/scoped-events.generated.ts @@ -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, diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index de0046c00e..016e87a969 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -823,6 +823,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' })]], diff --git a/packages/support/invariants/tsconfig.json b/packages/support/invariants/tsconfig.json index 6c5bc479b5..8fefb2d7eb 100644 --- a/packages/support/invariants/tsconfig.json +++ b/packages/support/invariants/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/agent" }, + { + "path": "../../goal/goal" + }, { "path": "../../core/scope" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7649345fc..4f1fa58dce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:* version: link:../packages/fs/fs-policy + '@deepseek-ai/dsh-goal': + specifier: workspace:* + version: link:../packages/goal/goal '@deepseek-ai/dsh-hooks-claude': specifier: workspace:* version: link:../packages/hooks/hooks-claude @@ -976,6 +979,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: @@ -1749,6 +1780,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 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 15780b17e0..289a2e9778 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -68,6 +68,12 @@ export const LINK_MAP: Record = { FsVersion: 'filesystem.md', FsWriteIntent: 'filesystem.md', FsWriteOutcome: 'filesystem.md', + CreateGoalRequest: 'goal.md', + CreateGoalSpec: 'goal.md', + EditGoalRequest: 'goal.md', + GoalChanged: 'goal.md', + GoalRef: 'goal.md', + GoalView: 'goal.md', LlmAdapter: 'llm-streaming.md', LlmService: 'llm-streaming.md', StreamChunk: 'llm-streaming.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0681153330..bd0ecc18df 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -58,6 +58,7 @@ const GROUP_ORDER = [ 'util', 'llm', 'core', + 'goal', 'bash', 'sandbox', 'fs', @@ -169,6 +170,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', diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts index 520375c9ae..f803f164cf 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -21,6 +21,7 @@ const GROUP_ORDER = [ 'util', 'llm', 'core', + 'goal', 'bash', 'fs', 'skill', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 202f2efb6e..1092c6c0b0 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -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": "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": "CreateGoalSpec", "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" }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 53f69b2cf5..ffcb649335 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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", diff --git a/tsconfig.build.json b/tsconfig.build.json index 36d6c6d462..668541ade3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -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" }, diff --git a/tsconfig.json b/tsconfig.json index 59bb4be2ca..156792259e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,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" }, diff --git a/website/.vitepress/config/api-sidebar.json b/website/.vitepress/config/api-sidebar.json index 3a2b11db3c..5fb3258805 100644 --- a/website/.vitepress/config/api-sidebar.json +++ b/website/.vitepress/config/api-sidebar.json @@ -54,6 +54,10 @@ "text": "ctx.fs", "link": "/zh-CN/api/harness/fs" }, + { + "text": "ctx.goals", + "link": "/zh-CN/api/harness/goals" + }, { "text": "ctx.llm", "link": "/zh-CN/api/harness/llm" diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index c3afe6b3aa..316af95351 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -2,7 +2,7 @@ # Harness events -Every event the harness packages declare on the cordis event bus (42 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). +Every event the harness packages declare on the cordis event bus (43 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). ## agent/* @@ -519,6 +519,32 @@ Single-slot decision for the next FileSystem.writeText. Calling `next()` yields [Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L53) +## goal/* + +### goal/changed + +**Mode:** `emit` + +```ts website-api +/** + * 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, change: GoalChanged): void +``` + +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. + +- `agent` — agent whose session owns the goal. +- `change` — fresh current projection or clear tombstone. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/types.ts#L166) + ## llm/* ### llm/stream diff --git a/website/zh-CN/api/harness/goals.md b/website/zh-CN/api/harness/goals.md new file mode 100644 index 0000000000..146cb04193 --- /dev/null +++ b/website/zh-CN/api/harness/goals.md @@ -0,0 +1,241 @@ + + +# 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#L97) + +### ctx.goals.resolveCreate(request) + +```ts website-api +/** + * Materialize deployment defaults and validate one create request. + * @param request - objective plus optional caller-selected round cap. + * @returns detached, fully resolved create specification. + */ +resolveCreate(request: CreateGoalRequest): CreateGoalSpec +``` + +Materialize deployment defaults and validate one create request. + +- `request` — objective plus optional caller-selected round cap. + +**Returns** detached, fully resolved create specification. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L122) + +### 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#L135) + +### 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#L149) + +### 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#L174) + +### 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#L195) + +### 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#L206) + +### 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#L231) + +### ctx.goals.block(agent, ref) + +```ts website-api +/** + * Mark an active goal blocked and disarm it. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the blocked view. + */ +block(agent: Agent, ref: GoalRef): GoalView +``` + +Mark an active goal blocked and disarm it. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the blocked view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L248) + +### ctx.goals.markUsageLimited(agent, ref) + +```ts website-api +/** + * Mark an active goal stopped by an external usage limit. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the usage-limited view. + */ +markUsageLimited(agent: Agent, ref: GoalRef): GoalView +``` + +Mark an active goal stopped by an external usage limit. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the usage-limited view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L258) + +### ctx.goals.markBudgetLimited(agent, ref) + +```ts website-api +/** + * Mark an active goal stopped at its configured round cap. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the budget-limited view. + */ +markBudgetLimited(agent: Agent, ref: GoalRef): GoalView +``` + +Mark an active goal stopped at its configured round cap. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the budget-limited view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L268) + +### 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#L295)