feat(goal): add persisted same-session goal domain
This commit is contained in:
@@ -9,6 +9,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| Group | Role | Release expectation |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
|
||||
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
|
||||
|
||||
@@ -250,6 +250,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: 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> | 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}',
|
||||
|
||||
9
packages/goal/README.md
Normal file
9
packages/goal/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# goal/ — persisted same-session goals
|
||||
|
||||
The goal family owns durable objective state independently of the model-facing tools and continuation policy that consume it.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` |
|
||||
|
||||
Goal state is part of the owning session log. Consumers depend on `dsh-goal`, not on the concrete agent loop; continuation behavior belongs in a separate plugin on the public agent seams.
|
||||
45
packages/goal/goal/README.md
Normal file
45
packages/goal/goal/README.md
Normal file
@@ -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_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log.
|
||||
|
||||
**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.
|
||||
44
packages/goal/goal/package.json
Normal file
44
packages/goal/goal/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-goal",
|
||||
"description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.17.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
379
packages/goal/goal/src/fold.ts
Normal file
379
packages/goal/goal/src/fold.ts
Normal file
@@ -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<SessionEvent, { type: 'context/message' }>
|
||||
|
||||
const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Set([
|
||||
'create',
|
||||
'edit',
|
||||
'pause',
|
||||
'resume',
|
||||
'complete',
|
||||
'block',
|
||||
'mark-usage-limited',
|
||||
'mark-budget-limited',
|
||||
])
|
||||
const PHASES: ReadonlySet<GoalPhase> = 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<GoalSnapshot['id']>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an empty replay accumulator.
|
||||
* @returns mutable state with no current goal or prior ref.
|
||||
*/
|
||||
export function emptyGoalFoldState(): GoalFoldState {
|
||||
return {
|
||||
goal: undefined,
|
||||
roundsStarted: 0,
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
lastRef: undefined,
|
||||
seenGoalIds: new Set(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a value is a JSON record rather than an array. */
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** Require one positive safe integer. */
|
||||
function positiveInteger(value: unknown, field: string): number {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
|
||||
throw new Error(`goal change ${field} must be a positive safe integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Require one non-negative safe integer. */
|
||||
function nonNegativeInteger(value: unknown, field: string): number {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error(`goal change ${field} must be a non-negative safe integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Decode 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<GoalOperation, 'clear'>)) {
|
||||
throw new Error('goal change operation is invalid')
|
||||
}
|
||||
const allowed = ['createdAt', 'goal', 'kind', 'operation', 'roundsStarted', 'updatedAt', 'version']
|
||||
if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) {
|
||||
throw new Error('goal snapshot change has an invalid shape')
|
||||
}
|
||||
const createdAt = nonNegativeInteger(value['createdAt'], 'createdAt')
|
||||
const updatedAt = nonNegativeInteger(value['updatedAt'], 'updatedAt')
|
||||
if (updatedAt < createdAt) throw new Error('goal change updatedAt cannot precede createdAt')
|
||||
return {
|
||||
kind: 'goal/change',
|
||||
version: GOAL_CHANGE_VERSION,
|
||||
operation: value['operation'] as Exclude<GoalOperation, 'clear'>,
|
||||
goal: decodeSnapshot(value['goal']),
|
||||
roundsStarted: nonNegativeInteger(value['roundsStarted'], 'roundsStarted'),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
} satisfies GoalSnapshotChangeMeta
|
||||
}
|
||||
|
||||
/** Narrow model attribution to a valid goal source. */
|
||||
function goalSource(source: MessageSource): GoalMessageSource | undefined {
|
||||
if (source.kind !== 'goal') return undefined
|
||||
if (typeof source.goalId !== 'string' || source.goalId.length === 0
|
||||
|| !Number.isSafeInteger(source.revision) || source.revision < 1
|
||||
|| !Number.isSafeInteger(source.round) || source.round < 0) {
|
||||
throw new Error('goal message source is invalid')
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
/** Require two snapshots to retain fields that only `edit` may replace. */
|
||||
function requireSameDefinition(current: GoalSnapshot, next: GoalSnapshot, operation: GoalOperation): void {
|
||||
if (next.objective !== current.objective || next.maxGoalRounds !== current.maxGoalRounds) {
|
||||
throw new Error(`goal ${operation} cannot change objective or maxGoalRounds`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Require one exact next revision of the current goal. */
|
||||
function requireNextRevision(current: GoalSnapshot, next: GoalRef, operation: GoalOperation): void {
|
||||
if (next.id !== current.id || next.revision !== current.revision + 1) {
|
||||
throw new Error(`goal ${operation} must advance the current goal by one revision`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one non-create snapshot operation against the preceding projection. */
|
||||
function validateSnapshotTransition(
|
||||
state: GoalFoldState,
|
||||
change: GoalSnapshotChangeMeta,
|
||||
current: GoalSnapshot,
|
||||
): void {
|
||||
const next = change.goal
|
||||
requireNextRevision(current, next, change.operation)
|
||||
/* v8 ignore next -- a current goal established by this fold always has an updatedAt */
|
||||
if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt')
|
||||
if (change.createdAt !== state.createdAt
|
||||
|| change.updatedAt < state.updatedAt
|
||||
|| change.roundsStarted !== state.roundsStarted) {
|
||||
throw new Error(`goal ${change.operation} does not preserve the current counters and timestamps`)
|
||||
}
|
||||
switch (change.operation) {
|
||||
case 'edit':
|
||||
if (next.phase !== current.phase) 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<GoalPhase> = 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 } },
|
||||
}
|
||||
}
|
||||
505
packages/goal/goal/src/index.ts
Normal file
505
packages/goal/goal/src/index.ts
Normal file
@@ -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<Config> = z.object({
|
||||
defaultMaxGoalRounds: z.number().default(256),
|
||||
})
|
||||
|
||||
private readonly resolved: ResolvedConfig
|
||||
private readonly caches = new WeakMap<Session, GoalCache>()
|
||||
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'goals')
|
||||
this.resolved = {
|
||||
defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256),
|
||||
}
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
this.cache(agent.session).activation = 'disarmed'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<GoalOperation, 'create' | 'edit' | 'clear'>,
|
||||
allowed: readonly GoalPhase[],
|
||||
phase: GoalPhase,
|
||||
activation: GoalActivation,
|
||||
): GoalView {
|
||||
const cache = this.prepareMutation(agent)
|
||||
const current = this.expectCurrent(cache, ref)
|
||||
if (!allowed.includes(current.phase)) throw this.transitionError(current, operation, allowed)
|
||||
return this.commitCurrent(agent, cache, operation, this.withPhase(current, phase), activation)
|
||||
}
|
||||
|
||||
/** Render a stable invalid-transition error. */
|
||||
private transitionError(current: GoalSnapshot, operation: GoalOperation, allowed: readonly GoalPhase[]): GoalError {
|
||||
return new GoalError(
|
||||
`cannot ${operation} goal "${current.id}" from phase "${current.phase}"; expected ${allowed.join(' or ')}`,
|
||||
'GOAL_INVALID_TRANSITION',
|
||||
)
|
||||
}
|
||||
|
||||
/** Commit a mutation that retains the current goal's derived counters/times. */
|
||||
private commitCurrent(
|
||||
agent: Agent,
|
||||
cache: GoalCache,
|
||||
operation: Exclude<GoalOperation, 'create' | 'clear'>,
|
||||
goal: GoalSnapshot,
|
||||
activation: GoalActivation,
|
||||
): GoalView {
|
||||
const createdAt = cache.state.createdAt
|
||||
/* v8 ignore next -- strict replay and every snapshot commit set createdAt whenever a current goal exists */
|
||||
if (createdAt === undefined) throw new Error('current goal cache lacks createdAt')
|
||||
return this.commitSnapshot(
|
||||
agent,
|
||||
cache,
|
||||
operation,
|
||||
goal,
|
||||
cache.state.roundsStarted,
|
||||
createdAt,
|
||||
this.nextMutationTime(cache),
|
||||
activation,
|
||||
)
|
||||
}
|
||||
|
||||
/** Clamp a current goal's next timestamp across backward wall-clock movement. */
|
||||
private nextMutationTime(cache: GoalCache): number {
|
||||
const updatedAt = cache.state.updatedAt
|
||||
/* v8 ignore next -- strict replay and every snapshot commit set updatedAt whenever a current goal exists */
|
||||
if (updatedAt === undefined) throw new Error('current goal cache lacks updatedAt')
|
||||
return Math.max(Date.now(), updatedAt)
|
||||
}
|
||||
|
||||
/** Build and commit one full-snapshot mutation. */
|
||||
private commitSnapshot(
|
||||
agent: Agent,
|
||||
cache: GoalCache,
|
||||
operation: Exclude<GoalOperation, 'clear'>,
|
||||
goal: GoalSnapshot,
|
||||
roundsStarted: number,
|
||||
createdAt: number,
|
||||
updatedAt: number,
|
||||
activation: GoalActivation,
|
||||
): GoalView {
|
||||
const change: GoalSnapshotChangeMeta = {
|
||||
kind: 'goal/change',
|
||||
version: GOAL_CHANGE_VERSION,
|
||||
operation,
|
||||
goal,
|
||||
roundsStarted,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
}
|
||||
this.commit(agent, cache, change, activation)
|
||||
const view = this.view(cache)
|
||||
/* v8 ignore next -- applyGoalChange installs the snapshot immediately before this read */
|
||||
if (view === undefined) throw new Error('snapshot commit cleared the goal unexpectedly')
|
||||
return view
|
||||
}
|
||||
|
||||
/** Accept one mutation into the agent log/FIFO, cache, and live event stream. */
|
||||
private commit(agent: Agent, cache: GoalCache, change: GoalChangeMeta, activation: GoalActivation): void {
|
||||
const ref = goalChangeRef(change)
|
||||
// snapshotJsonValue preserves its input type for callers that already have
|
||||
// a JsonValue; this interface is structurally JSON but intentionally has no
|
||||
// index signature, so narrow the validated output at this boundary.
|
||||
const meta = snapshotJsonValue(change) as JsonValue | undefined
|
||||
/* v8 ignore next -- validated goal changes contain only finite JSON primitives and records */
|
||||
if (meta === undefined) throw new Error('goal change is not losslessly JSON-serializable')
|
||||
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
|
||||
21
packages/goal/goal/src/render.ts
Normal file
21
packages/goal/goal/src/render.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** Model-visible rendering for durable goal mutations. */
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { GoalChangeMeta } from './types.ts'
|
||||
|
||||
/**
|
||||
* Render a complete goal snapshot or clear tombstone without hidden prose.
|
||||
* @param change - durable goal change metadata.
|
||||
* @returns the single 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: `<goal_state>${JSON.stringify(payload)}</goal_state>` }]
|
||||
}
|
||||
27
packages/goal/goal/src/runtime.ts
Normal file
27
packages/goal/goal/src/runtime.ts
Normal file
@@ -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'
|
||||
}
|
||||
}
|
||||
168
packages/goal/goal/src/types.ts
Normal file
168
packages/goal/goal/src/types.ts
Normal file
@@ -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<GoalOperation, 'clear'>
|
||||
readonly goal: GoalSnapshot
|
||||
readonly roundsStarted: number
|
||||
readonly createdAt: number
|
||||
readonly updatedAt: number
|
||||
}
|
||||
|
||||
/** Tombstone retained when the current goal is cleared. */
|
||||
export interface GoalClearChangeMeta {
|
||||
readonly kind: 'goal/change'
|
||||
readonly version: 1
|
||||
readonly operation: 'clear'
|
||||
readonly cleared: GoalRef
|
||||
readonly clearedAt: number
|
||||
}
|
||||
|
||||
/** Durable metadata union carried by a goal-owned `context/message`. */
|
||||
export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
|
||||
|
||||
/** Message attribution for durable goal state and continuation rounds. */
|
||||
export interface GoalMessageSource {
|
||||
readonly kind: 'goal'
|
||||
readonly goalId: GoalId
|
||||
readonly revision: number
|
||||
/** Zero for state changes; positive for admitted continuation rounds. */
|
||||
readonly round: number
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
goal: GoalMessageSource
|
||||
}
|
||||
}
|
||||
|
||||
/** Pure replay fold of durable goal facts. */
|
||||
export interface FoldedGoal {
|
||||
/** Current goal, absent after a clear or before the first create. */
|
||||
readonly goal?: GoalSnapshot
|
||||
/** Highest admitted round for the current goal. */
|
||||
readonly roundsStarted: number
|
||||
/** Current goal creation time, absent without a current goal. */
|
||||
readonly createdAt?: number
|
||||
/** Current goal mutation time, absent without a current goal. */
|
||||
readonly updatedAt?: number
|
||||
/** Latest mutation ref, including a clear tombstone. */
|
||||
readonly lastRef?: GoalRef
|
||||
}
|
||||
|
||||
/** Input whose omitted round cap is resolved by the service configuration. */
|
||||
export interface CreateGoalRequest {
|
||||
readonly objective: string
|
||||
readonly maxGoalRounds?: number
|
||||
}
|
||||
|
||||
/** 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: Agent, change: GoalChanged): void
|
||||
}
|
||||
}
|
||||
126
packages/goal/goal/tests/goal.e2e.ts
Normal file
126
packages/goal/goal/tests/goal.e2e.ts
Normal file
@@ -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 <something>" 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<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
const paths = await Promise.all(entries.map(async (entry) => {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) return jsonlFiles(path)
|
||||
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
|
||||
}))
|
||||
return paths.flat()
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
749
packages/goal/goal/tests/goal.spec.ts
Normal file
749
packages/goal/goal/tests/goal.spec.ts
Normal file
@@ -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> = {}): 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<GoalSnapshotChangeMeta['operation'], 'create'>,
|
||||
phase: GoalSnapshotChangeMeta['goal']['phase'],
|
||||
overrides: Partial<GoalSnapshotChangeMeta> = {},
|
||||
): 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<typeof foldGoal> {
|
||||
const session = new Session(SessionId(`validation-pair-${Math.random()}`))
|
||||
appendChange(session, first)
|
||||
appendChange(session, second)
|
||||
return foldGoal(session.events)
|
||||
}
|
||||
|
||||
it('ignores unrelated metadata and non-goal round sources', () => {
|
||||
expect(decodeGoalChange(undefined)).toBeUndefined()
|
||||
expect(decodeGoalChange({ kind: 'other' })).toBeUndefined()
|
||||
const session = new Session(SessionId('unrelated'))
|
||||
appendInjection(session, [{ type: 'text', text: 'other' }], {
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
meta: { kind: 'other' },
|
||||
})
|
||||
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 })
|
||||
const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'ordinary' }], source }, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 })
|
||||
})
|
||||
|
||||
it('rejects rounds attributed to another goal', () => {
|
||||
const change = snapshotChange()
|
||||
const session = new Session(SessionId('other-goal-round'), oneChange(change))
|
||||
appendRound(session, { id: GoalId('goal-other'), revision: 1 }, 1)
|
||||
expect(() => foldGoal(session.events)).toThrow('not the next admitted round')
|
||||
})
|
||||
|
||||
it('rejects unsupported versions, operations, and top-level shapes', () => {
|
||||
expect(() => decodeGoalChange({ ...snapshotChange(), version: 2 })).toThrow('unsupported goal change version')
|
||||
expect(() => decodeGoalChange({ ...snapshotChange(), operation: 'explode' })).toThrow('operation is invalid')
|
||||
expect(() => decodeGoalChange({ ...snapshotChange(), extra: true })).toThrow('snapshot change has an invalid shape')
|
||||
expect(() => decodeGoalChange({
|
||||
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'x', revision: 2 }, clearedAt: 1, extra: true,
|
||||
})).toThrow('clear change has an invalid shape')
|
||||
})
|
||||
|
||||
it('rejects invalid create and missing-current mutation sequences', () => {
|
||||
const base = snapshotChange()
|
||||
const invalidCreates: GoalSnapshotChangeMeta[] = [
|
||||
{ ...base, goal: { ...base.goal, revision: 2 } },
|
||||
{ ...base, goal: { ...base.goal, phase: 'paused' } },
|
||||
{ ...base, roundsStarted: 1 },
|
||||
]
|
||||
for (const change of invalidCreates) expect(() => foldGoal(oneChange(change))).toThrow('goal create requires')
|
||||
|
||||
const edit = mutation(base, 'edit', 'active')
|
||||
expect(() => foldGoal(oneChange(edit))).toThrow('requires a current goal')
|
||||
const clear: GoalChangeMeta = {
|
||||
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 12,
|
||||
}
|
||||
expect(() => foldGoal(oneChange(clear))).toThrow('clear requires a current goal')
|
||||
|
||||
const secondCreate = snapshotChange({
|
||||
goal: { ...base.goal, id: GoalId('goal-second') },
|
||||
createdAt: 20,
|
||||
updatedAt: 20,
|
||||
})
|
||||
expect(() => foldPair(base, secondCreate)).toThrow('goal create requires')
|
||||
})
|
||||
|
||||
it('rejects stale identity, counters, timestamps, and definition changes', () => {
|
||||
const base = snapshotChange()
|
||||
const invalid: GoalSnapshotChangeMeta[] = [
|
||||
mutation(base, 'edit', 'active', { goal: { ...base.goal, id: GoalId('goal-wrong'), revision: 2 } }),
|
||||
mutation(base, 'edit', 'active', { goal: { ...base.goal, revision: 3 } }),
|
||||
mutation(base, 'edit', 'active', { createdAt: 11 }),
|
||||
mutation(base, 'edit', 'active', { updatedAt: 9 }),
|
||||
mutation(base, 'edit', 'active', { roundsStarted: 1 }),
|
||||
mutation(base, 'pause', 'paused', {
|
||||
goal: { ...base.goal, revision: 2, phase: 'paused', objective: 'changed illegally' },
|
||||
}),
|
||||
mutation(base, 'pause', 'paused', {
|
||||
goal: { ...base.goal, revision: 2, phase: 'paused', maxGoalRounds: 3 },
|
||||
}),
|
||||
]
|
||||
for (const change of invalid) expect(() => foldPair(base, change)).toThrow()
|
||||
})
|
||||
|
||||
it('rejects invalid replayed lifecycle phase transitions', () => {
|
||||
const base = snapshotChange()
|
||||
const invalid: GoalSnapshotChangeMeta[] = [
|
||||
mutation(base, 'edit', 'paused'),
|
||||
mutation(base, 'pause', 'active'),
|
||||
mutation(base, 'resume', 'paused'),
|
||||
mutation(base, 'complete', 'active'),
|
||||
mutation(base, 'block', 'active'),
|
||||
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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
36
packages/goal/goal/tsconfig.json
Normal file
36
packages/goal/goal/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { Events } from 'cordis'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-goal'
|
||||
import type {} from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-subagent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -43,6 +44,7 @@ const scopedSubjectResolvers = Object.freeze({
|
||||
'agent/turn-continuation': adapt<'agent/turn-continuation'>(args => args[0]),
|
||||
'agent/turn-stop': adapt<'agent/turn-stop'>(args => args[0]),
|
||||
'approval/request': adapt<'approval/request'>(args => args[0].agent),
|
||||
'goal/changed': adapt<'goal/changed'>(args => args[0]),
|
||||
'session/created': null,
|
||||
'session/disposed': null,
|
||||
'session/event': null,
|
||||
|
||||
@@ -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' })]],
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user