Merge origin/master into codex/invariant-service-seam
# Conflicts: # docs/config-catalog.md # docs/event-producer-consumer.md # docs/module-graph.md # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/package.json # packages/examples/agent-spine-demo/src/index.ts # packages/examples/tui-demo/package.json # packages/support/invariants/src/scoped-events.generated.ts # packages/ui/acp/package.json # packages/ui/tui/package.json # packages/ui/tui/tests/tui.spec.ts # pnpm-lock.yaml
This commit is contained in:
@@ -5,5 +5,8 @@ The goal family owns durable objective state independently of the model-facing t
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` |
|
||||
| `goal-session/` | Same-session goal-round admission, outcome mapping, and lifecycle race fencing | — |
|
||||
| `tool-goal/` | Model-facing read/create/update tools with execution-time authority checks | — |
|
||||
| `command-goal/` | Human-facing `/goal` status and lifecycle control over the command plane | — |
|
||||
|
||||
Goal state is part of the owning session log. Consumers depend on `dsh-goal`, not on the concrete agent loop; continuation behavior belongs in a separate plugin on the public agent seams.
|
||||
|
||||
56
packages/goal/command-goal/README.md
Normal file
56
packages/goal/command-goal/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# @deepseek-ai/dsh-command-goal
|
||||
|
||||
Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI and ACP execute it without a model turn. The [human goal-command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions.
|
||||
|
||||
## Command contract
|
||||
|
||||
| Input | Result |
|
||||
|---|---|
|
||||
| `/goal` | Show the current objective, durable phase, round count/cap, process-local activation, and valid next commands; a blocked goal also shows its policy code and explanation, while no goal shows usage. |
|
||||
| `/goal <objective>` | Create and arm a goal, or replace a completed goal with a fresh identity. An unfinished goal is never replaced without an explicit clear. |
|
||||
| `/goal edit <objective>` | Edit the current objective without changing its phase or activation. Editing a completed goal creates a fresh active goal. |
|
||||
| `/goal pause` | Pause an active goal and disarm continuation. |
|
||||
| `/goal resume` | Resume a stopped goal or rearm an active goal after session resume/fork, subject to its remaining round cap. |
|
||||
| `/goal clear` | Clear the current pointer while retaining its durable history and tombstone. |
|
||||
|
||||
Control words are case-insensitive only when they occupy the complete input. Every other non-empty suffix is an objective, so `/goal pause after verification` creates that literal objective. The goal domain trims and validates objectives. Because the generic command plane has no modal editor or confirmation primitive, `edit` takes its replacement inline and an unfinished replacement returns a direct error instructing the user to edit or clear.
|
||||
|
||||
Expected domain rejections become stable direct command errors without exposing branded ids or revisions. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; every accepted mutation is persisted and made model-visible by `dsh-goal` rather than by this plugin.
|
||||
|
||||
## Composition
|
||||
|
||||
The producer injects `commands` and `goals`. A custom app mounts their owners plus this plugin; automatic continuation remains an independent choice:
|
||||
|
||||
```yaml
|
||||
- id: commands
|
||||
name: '@deepseek-ai/dsh-commands'
|
||||
- id: goal
|
||||
name: '@deepseek-ai/dsh-goal'
|
||||
- id: command-goal
|
||||
name: '@deepseek-ai/dsh-command-goal'
|
||||
```
|
||||
|
||||
The TUI and ACP demo apps enable the complete persisted-goal stack and this command by default; `goals: false` removes both. The UI-less `agent-spine-demo` requires an explicit `goals: {}` so headless one-shot callers do not silently change from one physical turn to a multi-round operation.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Human `/goal` control
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The slash input and direct status/error output are absent from model requests. An accepted mutation later appears through the goal domain's raw `<goal_state>` snapshot or clear tombstone; this preserves the model-visible-is-logged invariant without logging presentation text.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Reading status or receiving a direct command error adds no model tokens. Each accepted mutation adds the goal domain's retained full snapshot, and an enabled same-session driver may add later goal-round prompts.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Command discovery and direct output do not affect the cache. A mutation appends after the reusable history prefix; later compaction may replace the derived-history suffix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Plain-text interaction only** — the generic command registry has no modal edit form or replacement-confirmation callback; inline edit and explicit clear keep destructive intent deterministic on both TUI and ACP.
|
||||
- **No per-command round-cap argument** — `defaultMaxGoalRounds` remains deployment config, while a direct human request may ask the model to edit `max_goal_rounds` through the separately authorized goal tool.
|
||||
- **No continuous status widget** — bare `/goal` is the portable observation surface; adapter-specific badges and reconnectable command output remain future UI work.
|
||||
- **TUI and ACP only** — the headless CLI and JSON-RPC adapters do not consume `ctx.commands`. Ordinary human prompts can still authorize the model-facing goal tools when those are composed.
|
||||
45
packages/goal/command-goal/package.json
Normal file
45
packages/goal/command-goal/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-command-goal",
|
||||
"description": "Human-facing slash command for persisted same-session goals",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
170
packages/goal/command-goal/src/index.ts
Normal file
170
packages/goal/command-goal/src/index.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Human-facing `/goal` command over the persisted same-session goal domain.
|
||||
* @module @deepseek-ai/dsh-command-goal
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
|
||||
import { GoalError } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalPhase, GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
|
||||
|
||||
export const name = 'command-goal'
|
||||
export const inject = ['commands', 'goals']
|
||||
|
||||
const USAGE = 'Usage: /goal [<objective>|clear|edit <objective>|pause|resume]'
|
||||
|
||||
type GoalCommand =
|
||||
| { readonly kind: 'show' }
|
||||
| { readonly kind: 'create'; readonly objective: string }
|
||||
| { readonly kind: 'edit'; readonly objective: string }
|
||||
| { readonly kind: 'invalid-edit' }
|
||||
| { readonly kind: 'pause' }
|
||||
| { readonly kind: 'resume' }
|
||||
| { readonly kind: 'clear' }
|
||||
|
||||
/** Fail loudly if a locally closed union gains an unhandled member. */
|
||||
/* v8 ignore start -- closed-union backstop is unreachable without violating the TypeScript contract */
|
||||
function assertNever(value: never, label: string): never {
|
||||
throw new TypeError(`unknown ${label}: ${String(value)}`)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
/** Parse only the grammar owned by `/goal`; arbitrary other input is an objective. */
|
||||
function parseGoalCommand(rawInput: string): GoalCommand {
|
||||
const input = rawInput.trim()
|
||||
if (input.length === 0) return { kind: 'show' }
|
||||
const control = input.toLowerCase()
|
||||
if (control === 'clear') return { kind: 'clear' }
|
||||
if (control === 'pause') return { kind: 'pause' }
|
||||
if (control === 'resume') return { kind: 'resume' }
|
||||
if (control === 'edit') return { kind: 'invalid-edit' }
|
||||
if (/^edit(?=\s)/iu.test(input)) return { kind: 'edit', objective: input.slice(4).trim() }
|
||||
return { kind: 'create', objective: input }
|
||||
}
|
||||
|
||||
/** Human label for one durable goal phase. */
|
||||
function phaseLabel(phase: GoalPhase): string {
|
||||
switch (phase) {
|
||||
case 'active': return 'active'
|
||||
case 'paused': return 'paused'
|
||||
case 'blocked': return 'blocked'
|
||||
case 'complete': return 'complete'
|
||||
/* v8 ignore next 2 -- GoalPhase is closed and every member is handled above */
|
||||
default: return assertNever(phase, 'goal phase')
|
||||
}
|
||||
}
|
||||
|
||||
/** Commands that are meaningful from one exact live state. */
|
||||
function commandHint(goal: GoalView): string {
|
||||
if (goal.phase === 'active') {
|
||||
return goal.activation === 'armed'
|
||||
? '/goal edit <objective>, /goal pause, /goal clear'
|
||||
: '/goal edit <objective>, /goal resume, /goal clear'
|
||||
}
|
||||
switch (goal.phase) {
|
||||
case 'paused':
|
||||
case 'blocked':
|
||||
return '/goal edit <objective>, /goal resume, /goal clear'
|
||||
case 'complete':
|
||||
return '/goal <objective>, /goal clear'
|
||||
/* v8 ignore next 2 -- the active branch and every non-active phase are handled above */
|
||||
default: return assertNever(goal.phase, 'goal phase')
|
||||
}
|
||||
}
|
||||
|
||||
/** Render direct UI output without exposing compare-and-set internals. */
|
||||
function renderGoal(title: string, goal: GoalView): CommandResult {
|
||||
const reason = goal.phase === 'blocked' ? goal.blockedReason : undefined
|
||||
/* v8 ignore next -- durable replay guarantees every blocked goal carries its validated reason */
|
||||
if (goal.phase === 'blocked' && reason === undefined) throw new TypeError('blocked goal is missing its reason')
|
||||
const blocker = reason === undefined ? [] : [`Blocker: ${reason.code}: ${reason.message}`]
|
||||
return {
|
||||
kind: 'success',
|
||||
text: [
|
||||
title,
|
||||
`Status: ${phaseLabel(goal.phase)}`,
|
||||
...blocker,
|
||||
`Objective: ${goal.objective}`,
|
||||
`Rounds: ${goal.roundsStarted}/${goal.maxGoalRounds}`,
|
||||
`Activation: ${goal.activation}`,
|
||||
'',
|
||||
`Commands: ${commandHint(goal)}`,
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
/** Exact current compare-and-set ref. */
|
||||
function goalRef(goal: GoalView): GoalRef {
|
||||
return { id: goal.id, revision: goal.revision }
|
||||
}
|
||||
|
||||
/** Direct error for an operation that requires a current goal. */
|
||||
function missingGoal(action: string): CommandResult {
|
||||
return {
|
||||
kind: 'error',
|
||||
text: `No goal is currently set; /goal ${action} requires one. ${USAGE}`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute one parsed human command through the domain that owns persistence. */
|
||||
function executeGoalCommand(ctx: Context, invocation: CommandInvocation): CommandResult {
|
||||
const command = parseGoalCommand(invocation.rawInput)
|
||||
try {
|
||||
const current = ctx.goals.get(invocation.agent)
|
||||
switch (command.kind) {
|
||||
case 'show':
|
||||
return current === undefined
|
||||
? { kind: 'success', text: `No goal is currently set.\n${USAGE}` }
|
||||
: renderGoal('Goal', current)
|
||||
case 'invalid-edit':
|
||||
return { kind: 'error', text: `Goal editing requires a replacement objective.\n${USAGE}` }
|
||||
case 'create':
|
||||
if (current !== undefined && current.phase !== 'complete') {
|
||||
return {
|
||||
kind: 'error',
|
||||
text: `A goal is already ${phaseLabel(current.phase)}. Use /goal edit <objective> to change it or /goal clear before replacing it.`,
|
||||
}
|
||||
}
|
||||
return renderGoal('Goal created', ctx.goals.create(invocation.agent, { objective: command.objective }))
|
||||
case 'edit':
|
||||
if (current === undefined) return missingGoal('edit')
|
||||
if (current.phase === 'complete') {
|
||||
return renderGoal('Goal created', ctx.goals.create(invocation.agent, { objective: command.objective }))
|
||||
}
|
||||
return renderGoal(
|
||||
'Goal updated',
|
||||
ctx.goals.edit(invocation.agent, goalRef(current), { objective: command.objective }),
|
||||
)
|
||||
case 'pause':
|
||||
if (current === undefined) return missingGoal('pause')
|
||||
return renderGoal('Goal paused', ctx.goals.pause(invocation.agent, goalRef(current)))
|
||||
case 'resume':
|
||||
if (current === undefined) return missingGoal('resume')
|
||||
return renderGoal('Goal resumed', ctx.goals.resume(invocation.agent, goalRef(current)))
|
||||
case 'clear':
|
||||
if (current === undefined) return { kind: 'success', text: 'No goal to clear.' }
|
||||
ctx.goals.clear(invocation.agent, goalRef(current))
|
||||
return { kind: 'success', text: 'Goal cleared.' }
|
||||
/* v8 ignore next 2 -- GoalCommand is closed and every member is handled above */
|
||||
default: return assertNever(command, 'goal command')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof GoalError) {
|
||||
return {
|
||||
kind: 'error',
|
||||
text: 'The goal command is not valid for the current state. Run /goal to view available commands.',
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the Codex-shaped `/goal` command for every composed command adapter. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.commands.register({
|
||||
name: 'goal',
|
||||
description: 'set or view the goal for a long-running task',
|
||||
input: { hint: '[<objective>|clear|edit <objective>|pause|resume]' },
|
||||
handler: invocation => executeGoalCommand(ctx, invocation),
|
||||
})
|
||||
}
|
||||
30
packages/goal/command-goal/src/invariant.ts
Normal file
30
packages/goal/command-goal/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-command-goal`.
|
||||
* @module @deepseek-ai/dsh-command-goal/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-command-goal'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'command-goal-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this command adapter owns no event stream or state projection; accepted
|
||||
* mutations are checked by the goal domain and command dispatch behavior is covered by package tests.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
235
packages/goal/command-goal/tests/command-goal.spec.ts
Normal file
235
packages/goal/command-goal/tests/command-goal.spec.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import GoalService from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef } from '@deepseek-ai/dsh-goal'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
|
||||
|
||||
interface Harness {
|
||||
readonly ctx: Context
|
||||
readonly agent: Agent
|
||||
readonly session: Session
|
||||
readonly plugin: Awaited<ReturnType<Context['plugin']>>
|
||||
}
|
||||
|
||||
/** Number the next balanced injection or message turn. */
|
||||
function nextTurn(session: Session): number {
|
||||
return session.events.reduce(
|
||||
(maximum, event) => event.type === 'turn/start' ? Math.max(maximum, event.data.turn) : maximum,
|
||||
0,
|
||||
) + 1
|
||||
}
|
||||
|
||||
/** Append one idle injection using the public Agent contract's balanced shape. */
|
||||
function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void {
|
||||
const source: MessageSource = options?.source ?? { kind: 'user' }
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', {
|
||||
content,
|
||||
source,
|
||||
...options?.meta === undefined ? {} : { meta: options.meta },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
/** Build a live idle agent accepted by the exact-identity goal service. */
|
||||
function stubAgent(id: string): { agent: Agent; session: Session } {
|
||||
const session = new Session(SessionId(id))
|
||||
let status: AgentStatus = 'idle'
|
||||
const agent: Agent = {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
send() {},
|
||||
steer() {},
|
||||
inject(content, options) { appendInjection(session, content, options) },
|
||||
cancel() { status = 'idle' },
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
return { agent, session }
|
||||
}
|
||||
|
||||
/** Mount the real command registry, goal domain, and producer. */
|
||||
async function harness(): Promise<Harness> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(GoalService)
|
||||
const plugin = await ctx.plugin(commandGoal)
|
||||
const { agent, session } = stubAgent(`command-goal-${Math.random()}`)
|
||||
ctx.agents.register(agent)
|
||||
return { ctx, agent, session, plugin }
|
||||
}
|
||||
|
||||
/** Execute `/goal` through the same registry boundary as a UI adapter. */
|
||||
async function run(test: Harness, suffix = ''): Promise<NonNullable<Awaited<ReturnType<CommandService['execute']>>>> {
|
||||
const result = await test.ctx.commands.execute(
|
||||
test.agent,
|
||||
`/goal${suffix}`,
|
||||
new AbortController().signal,
|
||||
)
|
||||
if (result === undefined) throw new Error('goal command was not registered')
|
||||
return result
|
||||
}
|
||||
|
||||
/** Current exact compare-and-set ref. */
|
||||
function ref(goal: NonNullable<ReturnType<GoalService['get']>>): GoalRef {
|
||||
return { id: goal.id, revision: goal.revision }
|
||||
}
|
||||
|
||||
describe('@deepseek-ai/dsh-command-goal registration', () => {
|
||||
it('registers one global command with Loader-safe exports and disposes it', async () => {
|
||||
const test = await harness()
|
||||
expect(commandGoal.name).toBe('command-goal')
|
||||
expect(commandGoal.inject).toEqual(['commands', 'goals'])
|
||||
expect('default' in commandGoal).toBe(false)
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
expect(loader.unwrapExports(commandGoal)).toBe(commandGoal)
|
||||
|
||||
expect(test.ctx.commands.list(test.agent)).toContainEqual({
|
||||
name: 'goal',
|
||||
description: 'set or view the goal for a long-running task',
|
||||
input: { hint: '[<objective>|clear|edit <objective>|pause|resume]' },
|
||||
})
|
||||
expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined()
|
||||
|
||||
await test.plugin.dispose()
|
||||
expect(test.ctx.commands.find(test.agent, 'goal')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('/goal human command', () => {
|
||||
it('shows an empty status without mutating the session', async () => {
|
||||
const test = await harness()
|
||||
await expect(run(test)).resolves.toEqual({
|
||||
kind: 'success',
|
||||
text: 'No goal is currently set.\nUsage: /goal [<objective>|clear|edit <objective>|pause|resume]',
|
||||
})
|
||||
expect(test.session.events).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a trimmed objective and refuses silent replacement of unfinished work', async () => {
|
||||
const test = await harness()
|
||||
const created = await run(test, '\n finish the release ')
|
||||
expect(created.kind).toBe('success')
|
||||
expect(created.text).toContain('Goal created\nStatus: active')
|
||||
expect(created.text).toContain('Objective: finish the release')
|
||||
expect(created.text).toContain('Rounds: 0/256')
|
||||
expect(created.text).toContain('Activation: armed')
|
||||
expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release')
|
||||
expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end'])
|
||||
|
||||
const count = test.session.events.length
|
||||
await expect(run(test, ' replacement')).resolves.toEqual({
|
||||
kind: 'error',
|
||||
text: 'A goal is already active. Use /goal edit <objective> to change it or /goal clear before replacing it.',
|
||||
})
|
||||
expect(test.session.events).toHaveLength(count)
|
||||
})
|
||||
|
||||
it('treats only exact control words as controls', async () => {
|
||||
const test = await harness()
|
||||
await run(test, ' pause everything only after verification')
|
||||
expect(test.ctx.goals.get(test.agent)?.objective).toBe('pause everything only after verification')
|
||||
})
|
||||
|
||||
it('edits inline, requires an objective, and starts a new goal when the old one is complete', async () => {
|
||||
const empty = await harness()
|
||||
const invalidEdit = await run(empty, ' edit')
|
||||
expect(invalidEdit.kind).toBe('error')
|
||||
expect(invalidEdit.text).toContain('requires a replacement objective')
|
||||
const missingEdit = await run(empty, ' edit replacement')
|
||||
expect(missingEdit.kind).toBe('error')
|
||||
expect(missingEdit.text).toContain('/goal edit requires one')
|
||||
|
||||
const test = await harness()
|
||||
await run(test, ' first')
|
||||
const first = test.ctx.goals.get(test.agent)!
|
||||
const updated = await run(test, ' EDIT\n second ')
|
||||
expect(updated.kind).toBe('success')
|
||||
expect(updated.text).toContain('Goal updated')
|
||||
expect(test.ctx.goals.get(test.agent)).toMatchObject({ id: first.id, objective: 'second', revision: 2 })
|
||||
|
||||
const current = test.ctx.goals.get(test.agent)!
|
||||
test.ctx.goals.complete(test.agent, ref(current))
|
||||
const replacement = await run(test, ' edit third')
|
||||
expect(replacement.kind).toBe('success')
|
||||
expect(replacement.text).toContain('Goal created')
|
||||
expect(test.ctx.goals.get(test.agent)).toMatchObject({ objective: 'third', revision: 1 })
|
||||
expect(test.ctx.goals.get(test.agent)?.id).not.toBe(first.id)
|
||||
})
|
||||
|
||||
it('returns direct missing-state results for pause, resume, and clear', async () => {
|
||||
const test = await harness()
|
||||
const missingPause = await run(test, ' pause')
|
||||
expect(missingPause.kind).toBe('error')
|
||||
expect(missingPause.text).toContain('/goal pause requires one')
|
||||
const missingResume = await run(test, ' resume')
|
||||
expect(missingResume.kind).toBe('error')
|
||||
expect(missingResume.text).toContain('/goal resume requires one')
|
||||
await expect(run(test, ' clear')).resolves.toEqual({ kind: 'success', text: 'No goal to clear.' })
|
||||
})
|
||||
|
||||
it('pauses, resumes, clears, and converts expected domain rejections to command errors', async () => {
|
||||
const test = await harness()
|
||||
await run(test, ' work')
|
||||
const redundantResume = await run(test, ' RESUME')
|
||||
expect(redundantResume).toEqual({
|
||||
kind: 'error',
|
||||
text: 'The goal command is not valid for the current state. Run /goal to view available commands.',
|
||||
})
|
||||
const paused = await run(test, ' PAUSE')
|
||||
expect(paused.kind).toBe('success')
|
||||
expect(paused.text).toContain('Goal paused')
|
||||
expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'paused', activation: 'disarmed' })
|
||||
const resumed = await run(test, ' resume')
|
||||
expect(resumed.kind).toBe('success')
|
||||
expect(resumed.text).toContain('Goal resumed')
|
||||
expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', activation: 'armed' })
|
||||
await expect(run(test, ' clear')).resolves.toEqual({ kind: 'success', text: 'Goal cleared.' })
|
||||
expect(test.ctx.goals.get(test.agent)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows every durable phase and distinguishes disarmed active state', async () => {
|
||||
const test = await harness()
|
||||
test.ctx.goals.create(test.agent, { objective: 'state matrix', maxGoalRounds: 1 })
|
||||
test.ctx.goals.disarm(test.agent)
|
||||
expect((await run(test)).text)
|
||||
.toContain('Status: active\nObjective: state matrix\nRounds: 0/1\nActivation: disarmed')
|
||||
expect((await run(test)).text).toContain('/goal resume')
|
||||
|
||||
let goal = test.ctx.goals.get(test.agent)!
|
||||
goal = test.ctx.goals.resume(test.agent, ref(goal))
|
||||
goal = test.ctx.goals.pause(test.agent, ref(goal))
|
||||
expect((await run(test)).text).toContain('Status: paused')
|
||||
|
||||
goal = test.ctx.goals.resume(test.agent, ref(goal))
|
||||
goal = test.ctx.goals.block(test.agent, ref(goal), {
|
||||
code: 'upstream-unavailable',
|
||||
message: 'Provider unavailable',
|
||||
})
|
||||
const blocked = await run(test)
|
||||
expect(blocked.text).toContain('Status: blocked')
|
||||
expect(blocked.text).toContain('Blocker: upstream-unavailable: Provider unavailable')
|
||||
|
||||
goal = test.ctx.goals.resume(test.agent, ref(goal))
|
||||
test.ctx.goals.complete(test.agent, ref(goal))
|
||||
const complete = await run(test)
|
||||
expect(complete.text).toContain('Status: complete')
|
||||
expect(complete.text).toContain('Commands: /goal <objective>, /goal clear')
|
||||
})
|
||||
|
||||
it('does not turn unexpected implementation failures into expected command results', async () => {
|
||||
const test = await harness()
|
||||
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { throw new Error('unexpected failure') })
|
||||
await expect(run(test)).rejects.toThrow('unexpected failure')
|
||||
})
|
||||
})
|
||||
27
packages/goal/command-goal/tsconfig.json
Normal file
27
packages/goal/command-goal/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../goal"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
71
packages/goal/goal-session/README.md
Normal file
71
packages/goal/goal-session/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# @deepseek-ai/dsh-goal-session
|
||||
|
||||
Same-session continuation driver for [`ctx.goals`](../goal/README.md). It turns an active, armed goal into sequential [goal rounds](../../../docs/glossary.md#goal-round) through the public `Agent` and session seams; the [same-session driver Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md) owns the race and lifecycle rationale.
|
||||
|
||||
## Composition
|
||||
|
||||
```yaml
|
||||
- id: goal
|
||||
name: '@deepseek-ai/dsh-goal'
|
||||
|
||||
- id: tool-goal
|
||||
name: '@deepseek-ai/dsh-tool-goal'
|
||||
|
||||
- id: goal-session
|
||||
name: '@deepseek-ai/dsh-goal-session'
|
||||
```
|
||||
|
||||
The plugin has no tunable configuration. `maxGoalRounds` belongs to the goal definition, while the model-facing blocked threshold belongs to [`dsh-tool-goal`](../tool-goal/README.md); duplicating either value in the driver could produce divergent policy.
|
||||
|
||||
## Round contract
|
||||
|
||||
When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `<goal_round>` prompt with `GoalMessageSource`. Admission through `agent/prompt-submit` verifies the complete queued record and current goal both before and after downstream prompt hooks; only the accepted `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number.
|
||||
|
||||
One goal round owns one ordinary session turn, and that turn may contain several model/tool steps. Human messages remain ordinary turns and do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until that work settles; a pending automatic prompt in a mixed batch is rejected and re-reserved only after the agent becomes idle.
|
||||
|
||||
The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, treats the current workspace, tool results, and durable session state as authoritative, requires evidence before completion, and tells the model to leave the goal active when work remains. Quoting preserves multiline or tag-like objective text as data. Goal lifecycle mutations still require the independent authority checks in `dsh-tool-goal`.
|
||||
|
||||
## Settlement policy
|
||||
|
||||
| Durable turn outcome | Goal action | Automatic retry |
|
||||
|---|---|---|
|
||||
| `completed` with goal still active and armed | admit the next round, or block with code `round-limit` at the cap | yes |
|
||||
| cancellation of a reserved/admitted goal round, or its `aborted` outcome | `paused` | no |
|
||||
| cancellation with no goal-round attempt | keep durable phase; disarm activation | no |
|
||||
| `error` with `RATE_LIMIT` or `QUOTA` | `blocked` with code `usage-limited` | no |
|
||||
| other `error`, `max-tokens`, or a non-stale prompt rejection | `blocked` with a diagnostic code and message | no |
|
||||
| durability failure, disposal, interruption, or unknown future outcome | disarm or block for inspection | no |
|
||||
|
||||
A goal mutation made during its round supersedes settlement of the older revision. Completion, pause, blocking, and edits therefore remain authoritative even if the physical turn closes afterward. No abnormal result is retried automatically.
|
||||
|
||||
## Lifecycle and durability
|
||||
|
||||
`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A closing flush failure arrives through `agent/error`; the driver associates it with the exact closed turn even if a later one-shot injection has appended another turn, then disarms before another round can start.
|
||||
|
||||
Activation is never inherited when this plugin loads over an existing agent. `GoalService.disarm()` removes process-local authority without changing durable phase, revision, or history; explicit human-authorized resume records the later reactivation. The same rule applies after session resume and fork through the goal domain's `agent/session-start` handling.
|
||||
|
||||
Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` before clearing queues or aborting a step. The plugin durably pauses an active goal only when the cancellation owns a reserved or admitted goal attempt; cancellation of unrelated human work merely disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels an admitted round, and awaits the driver plus agent quiescence while its event fence remains installed.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Goal-round prompt
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each admitted round is one retained user-role `<goal_round>` block naming the full objective and positive round number. Earlier human messages, goal-state snapshots, assistant output, and tool records remain in the same session history.
|
||||
|
||||
#### Token effect
|
||||
|
||||
One fixed instruction block plus the objective is added per admitted round. Later requests resend retained rounds until compaction shadows them; no fresh agent or copied conversation prefix is created.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only within an epoch: each admitted round extends the existing conversation after its reusable prefix. Compaction may replace the derived-history suffix and move the reusable boundary.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No independent evaluator** — the model-facing goal policy decides when evidence is sufficient for completion and whether a blocker is semantically unchanged; evaluator-backed certification remains deferred.
|
||||
- **Same-session execution only** — this package deliberately does not spawn a fresh agent, fork a session prefix, or implement Ralph-style independent attempts; that workflow belongs to its own plugin layer.
|
||||
- **Accepted-queue unload race** — Cordis plugin unload is asynchronous. A goal prompt already accepted by the agent inbox can begin and consume its round before unload starts; teardown then cancels the request, disarms the goal, and awaits quiescence. No later round starts.
|
||||
- **Round cap, not resource budget** — token, currency, time, and provider quota policies remain independent; observed `RATE_LIMIT` and `QUOTA` stops only map into the blocked reason code `usage-limited`.
|
||||
- **No abnormal auto-retry** — transient provider and persistence failures require a later human-authorized resume rather than an implicit retry policy.
|
||||
49
packages/goal/goal-session/package.json
Normal file
49
packages/goal/goal-session/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-goal-session",
|
||||
"description": "Race-fenced same-session goal-round driver",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
456
packages/goal/goal-session/src/index.ts
Normal file
456
packages/goal/goal-session/src/index.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* Same-session goal-round driver over public agent, session, and goal seams.
|
||||
* @module @deepseek-ai/dsh-goal-session
|
||||
*/
|
||||
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { FiberState } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { classifyGoalRound } from './outcome.ts'
|
||||
import type { GoalRoundOutcome } from './outcome.ts'
|
||||
import { renderGoalRoundPrompt } from './prompt.ts'
|
||||
|
||||
export { classifyGoalRound } from './outcome.ts'
|
||||
export type { GoalRoundOutcome } from './outcome.ts'
|
||||
export { renderGoalRoundPrompt } from './prompt.ts'
|
||||
|
||||
export const name = 'goal-session'
|
||||
export const inject = ['agents', 'goals', 'sessions']
|
||||
|
||||
const STALE_ROUND_REASON = 'stale goal-round reservation'
|
||||
|
||||
/** Identity reserved before a goal continuation enters the agent inbox. */
|
||||
interface RoundIdentity {
|
||||
readonly goalId: GoalRef['id']
|
||||
readonly revision: number
|
||||
readonly round: number
|
||||
}
|
||||
|
||||
/** One queued or admitted attempt, retained until its physical turn settles. */
|
||||
interface RoundAttempt extends RoundIdentity {
|
||||
readonly content: ContentBlock[]
|
||||
phase: 'queued' | 'admitted'
|
||||
turn: number | undefined
|
||||
reason: TurnEndReason | undefined
|
||||
rejectedReason: string | undefined
|
||||
stale: boolean
|
||||
}
|
||||
|
||||
/** Serialized process-local scheduling state for one exact Agent lifecycle. */
|
||||
interface DriverState {
|
||||
readonly agent: Agent
|
||||
attempt: RoundAttempt | undefined
|
||||
openTurn: number | undefined
|
||||
competingQueued: boolean
|
||||
needsCheckpoint: boolean
|
||||
requested: boolean
|
||||
run: Promise<void> | undefined
|
||||
stopping: boolean
|
||||
readonly flushFailedTurns: Set<number>
|
||||
}
|
||||
|
||||
/** Whether a source identifies an automatic, positive-numbered goal round. */
|
||||
function isGoalRoundSource(source: MessageSource): source is GoalMessageSource {
|
||||
return source.kind === 'goal' && source.round > 0
|
||||
}
|
||||
|
||||
/** Compare a source to one reserved identity. */
|
||||
function sameRound(source: GoalMessageSource, round: RoundIdentity): boolean {
|
||||
return source.goalId === round.goalId
|
||||
&& source.revision === round.revision
|
||||
&& source.round === round.round
|
||||
}
|
||||
|
||||
/** Compare the complete queued record to the driver's reservation. */
|
||||
function sameQueued(content: ContentBlock[], source: MessageSource, attempt: RoundAttempt): boolean {
|
||||
return isGoalRoundSource(source) && sameRound(source, attempt) && isDeepStrictEqual(content, attempt.content)
|
||||
}
|
||||
|
||||
/** Exact current ref for a view. */
|
||||
function goalRef(goal: GoalView): GoalRef {
|
||||
return { id: goal.id, revision: goal.revision }
|
||||
}
|
||||
|
||||
/** Human-readable unexpected values for logs. */
|
||||
function renderThrown(value: unknown): string {
|
||||
return value instanceof Error ? value.message : String(value)
|
||||
}
|
||||
|
||||
/** Install automatic same-session continuation and its race fences. */
|
||||
export function apply(ctx: Context): void {
|
||||
const states = new Map<Agent, DriverState>()
|
||||
|
||||
/** Create state for an exact currently live agent. */
|
||||
function stateFor(agent: Agent): DriverState {
|
||||
const existing = states.get(agent)
|
||||
if (existing !== undefined) return existing
|
||||
const state: DriverState = {
|
||||
agent,
|
||||
attempt: undefined,
|
||||
openTurn: undefined,
|
||||
competingQueued: false,
|
||||
needsCheckpoint: false,
|
||||
requested: false,
|
||||
run: undefined,
|
||||
stopping: false,
|
||||
flushFailedTurns: new Set(),
|
||||
}
|
||||
states.set(agent, state)
|
||||
return state
|
||||
}
|
||||
|
||||
/** Read only when the exact Agent remains live. */
|
||||
function currentGoal(state: DriverState): GoalView | undefined {
|
||||
if (ctx.agents.get(state.agent.id) !== state.agent || state.agent.status === 'disposed') return undefined
|
||||
return ctx.goals.get(state.agent)
|
||||
}
|
||||
|
||||
/** Whether this exact lifecycle is quiescent with no competing prompt. */
|
||||
function readyToDrive(state: DriverState): boolean {
|
||||
return ctx.fiber.state === FiberState.ACTIVE
|
||||
&& !state.stopping
|
||||
&& ctx.agents.get(state.agent.id) === state.agent
|
||||
&& state.agent.status === 'idle'
|
||||
&& !state.competingQueued
|
||||
}
|
||||
|
||||
/** Recheck every condition that an awaited checkpoint may have changed. */
|
||||
function readyAfterCheckpoint(state: DriverState): boolean {
|
||||
return readyToDrive(state) && !state.needsCheckpoint
|
||||
}
|
||||
|
||||
/** Remove automatic authority while preserving the durable phase. */
|
||||
function disarm(state: DriverState): void {
|
||||
try {
|
||||
const goal = currentGoal(state)
|
||||
if (goal?.activation === 'armed') ctx.goals.disarm(state.agent)
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`goal-session: could not disarm agent "${state.agent.id}": ${renderThrown(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one closed-round outcome only to the exact still-current revision. */
|
||||
function applyOutcome(state: DriverState, goal: GoalView, outcome: GoalRoundOutcome): void {
|
||||
const ref = goalRef(goal)
|
||||
switch (outcome.kind) {
|
||||
case 'continue':
|
||||
return
|
||||
case 'pause':
|
||||
ctx.goals.pause(state.agent, ref)
|
||||
return
|
||||
case 'blocked':
|
||||
ctx.goals.block(state.agent, ref, { code: outcome.code, message: outcome.message })
|
||||
return
|
||||
case 'disarm':
|
||||
ctx.goals.disarm(state.agent)
|
||||
return
|
||||
/* v8 ignore next 2 -- GoalRoundOutcome is closed and every member is handled above */
|
||||
default:
|
||||
assertNever(outcome, 'goal round outcome')
|
||||
}
|
||||
}
|
||||
|
||||
/** Process a settled attempt, then reserve at most one next round. */
|
||||
async function drive(state: DriverState): Promise<void> {
|
||||
const { agent } = state
|
||||
if (!readyToDrive(state)) return
|
||||
|
||||
if (state.needsCheckpoint) {
|
||||
state.needsCheckpoint = false
|
||||
try {
|
||||
await ctx.sessions.flush(agent.session)
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`goal-session: durability checkpoint failed for agent "${agent.id}": ${renderThrown(error)}`)
|
||||
const goal = currentGoal(state)
|
||||
if (goal !== undefined) applyOutcome(state, goal, { kind: 'disarm', reason: 'durability-failed' })
|
||||
return
|
||||
}
|
||||
// A mutation or ordinary prompt may have arrived while the checkpoint
|
||||
// was settling. Give it its own checkpoint / turn before reserving.
|
||||
if (!readyAfterCheckpoint(state)) return
|
||||
}
|
||||
|
||||
const attempt = state.attempt
|
||||
if (attempt !== undefined) {
|
||||
if (attempt.reason === undefined) return
|
||||
state.attempt = undefined
|
||||
const turn = attempt.turn
|
||||
/* v8 ignore next -- a closed attempt acquired its turn at turn/start */
|
||||
if (turn === undefined) throw new Error('settled goal-round attempt lacks a turn')
|
||||
const durable = !state.flushFailedTurns.delete(turn)
|
||||
const goal = currentGoal(state)
|
||||
if (goal !== undefined && goal.id === attempt.goalId && goal.revision === attempt.revision
|
||||
&& goal.phase === 'active' && goal.activation === 'armed') {
|
||||
const outcome = attempt.phase === 'queued' && attempt.rejectedReason !== undefined && !attempt.stale
|
||||
? { kind: 'blocked', code: 'prompt-rejected', message: attempt.rejectedReason } as const
|
||||
: classifyGoalRound(attempt.reason, durable)
|
||||
if (!attempt.stale) applyOutcome(state, goal, outcome)
|
||||
}
|
||||
if (!readyToDrive(state)) return
|
||||
}
|
||||
|
||||
const goal = currentGoal(state)
|
||||
if (goal === undefined || goal.phase !== 'active' || goal.activation !== 'armed') return
|
||||
if (goal.roundsStarted >= goal.maxGoalRounds) {
|
||||
ctx.goals.block(agent, goalRef(goal), {
|
||||
code: 'round-limit',
|
||||
message: `Goal reached its configured limit of ${goal.maxGoalRounds} rounds.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const round = goal.roundsStarted + 1
|
||||
const content = renderGoalRoundPrompt(goal, round)
|
||||
const reservation: RoundAttempt = {
|
||||
goalId: goal.id,
|
||||
revision: goal.revision,
|
||||
round,
|
||||
content,
|
||||
phase: 'queued',
|
||||
turn: undefined,
|
||||
reason: undefined,
|
||||
rejectedReason: undefined,
|
||||
stale: false,
|
||||
}
|
||||
state.attempt = reservation
|
||||
try {
|
||||
agent.send(content, {
|
||||
source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round },
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
state.attempt = undefined
|
||||
ctx.logger.warn(`goal-session: could not queue round ${round} for agent "${agent.id}": ${renderThrown(error)}`)
|
||||
const latest = currentGoal(state)
|
||||
if (latest !== undefined && latest.id === goal.id && latest.revision === goal.revision
|
||||
&& latest.phase === 'active' && latest.activation === 'armed') {
|
||||
ctx.goals.block(agent, goalRef(latest), {
|
||||
code: 'queue-failed',
|
||||
message: `Could not queue goal round ${round}: ${renderThrown(error)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Coalesce triggers onto one agent-local serialized driver. */
|
||||
function requestDrive(state: DriverState): void {
|
||||
/* v8 ignore next -- teardown may race a final trigger after synchronously closing admission */
|
||||
if (state.stopping) return
|
||||
state.requested = true
|
||||
if (state.run !== undefined) return
|
||||
let run: Promise<void>
|
||||
try {
|
||||
run = ctx.agents.withoutInitiator(async () => {
|
||||
while (state.requested && !state.stopping) {
|
||||
state.requested = false
|
||||
try {
|
||||
await drive(state)
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`goal-session: driver failed for agent "${state.agent.id}": ${renderThrown(error)}`)
|
||||
disarm(state)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`goal-session: could not start driver for agent "${state.agent.id}": ${renderThrown(error)}`)
|
||||
disarm(state)
|
||||
return
|
||||
}
|
||||
state.run = run
|
||||
const retire = (): void => {
|
||||
state.run = undefined
|
||||
if (state.requested && !state.stopping) requestDrive(state)
|
||||
}
|
||||
void run.then(retire, (error: unknown) => {
|
||||
ctx.logger.warn(`goal-session: driver task rejected for agent "${state.agent.id}": ${renderThrown(error)}`)
|
||||
disarm(state)
|
||||
retire()
|
||||
})
|
||||
}
|
||||
|
||||
// One composite effect owns every listener and the quiescent close. Cordis
|
||||
// unloads sibling effects concurrently; nesting makes the close run first
|
||||
// and keeps the admission fence installed until its drain settles.
|
||||
ctx.effect(function* () {
|
||||
/** Mark a post-turn persistence failure before idle scheduling can run. */
|
||||
ctx.on('agent/error', (agent, turn) => {
|
||||
const state = stateFor(agent)
|
||||
const closed = agent.session.events.some(event => event.type === 'turn/end' && event.data.turn === turn)
|
||||
if (!closed) return
|
||||
if (state.attempt?.turn === turn) state.flushFailedTurns.add(turn)
|
||||
disarm(state)
|
||||
})
|
||||
|
||||
ctx.on('agent/created', (agent) => { stateFor(agent) })
|
||||
ctx.on('agent/disposed', (agent) => { states.delete(agent) })
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
const state = stateFor(agent)
|
||||
state.attempt = undefined
|
||||
state.openTurn = undefined
|
||||
state.competingQueued = false
|
||||
state.needsCheckpoint = false
|
||||
state.flushFailedTurns.clear()
|
||||
})
|
||||
ctx.on('agent/status', (agent, status) => {
|
||||
const state = stateFor(agent)
|
||||
if (status === 'disposed') {
|
||||
state.stopping = true
|
||||
return
|
||||
}
|
||||
if (status === 'idle') {
|
||||
state.competingQueued = false
|
||||
requestDrive(state)
|
||||
}
|
||||
})
|
||||
ctx.on('agent/queued', (agent, content, info) => {
|
||||
const state = stateFor(agent)
|
||||
const attempt = state.attempt
|
||||
if (attempt !== undefined && sameQueued(content, info.source, attempt)) return
|
||||
state.competingQueued = true
|
||||
if (attempt?.phase === 'queued') attempt.stale = true
|
||||
})
|
||||
ctx.on('agent/cancel-requested', (agent, reason) => {
|
||||
const state = stateFor(agent)
|
||||
const attempt = state.attempt
|
||||
state.attempt = undefined
|
||||
state.competingQueued = false
|
||||
const goal = currentGoal(state)
|
||||
if (goal?.phase === 'active' && goal.activation === 'armed') {
|
||||
if (attempt === undefined) {
|
||||
disarm(state)
|
||||
return
|
||||
}
|
||||
try {
|
||||
applyOutcome(state, goal, { kind: 'pause', reason })
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`)
|
||||
disarm(state)
|
||||
}
|
||||
}
|
||||
})
|
||||
ctx.on('goal/changed', (agent) => {
|
||||
const state = stateFor(agent)
|
||||
state.needsCheckpoint = true
|
||||
requestDrive(state)
|
||||
})
|
||||
|
||||
ctx.on('session/event', (session: Session, event: SessionEvent) => {
|
||||
const agent = ctx.agents.get(session.id)
|
||||
if (agent === undefined || agent.session !== session) return
|
||||
const state = stateFor(agent)
|
||||
switch (event.type) {
|
||||
case 'turn/start':
|
||||
state.openTurn = event.data.turn
|
||||
if (state.attempt !== undefined && isGoalRoundSource(event.data.trigger.source)
|
||||
&& sameRound(event.data.trigger.source, state.attempt)) {
|
||||
state.attempt.turn = event.data.turn
|
||||
}
|
||||
return
|
||||
case 'user/message':
|
||||
if (state.attempt !== undefined && isGoalRoundSource(event.data.source)
|
||||
&& sameRound(event.data.source, state.attempt)) {
|
||||
state.attempt.phase = 'admitted'
|
||||
/* v8 ignore next -- this driver's admitted message always follows its observed turn/start */
|
||||
if (state.openTurn !== undefined) state.attempt.turn = state.openTurn
|
||||
}
|
||||
return
|
||||
case 'prompt/blocked':
|
||||
if (state.attempt !== undefined && state.attempt.phase === 'queued'
|
||||
&& isGoalRoundSource(event.data.source) && sameRound(event.data.source, state.attempt)) {
|
||||
/* v8 ignore next -- this driver's rejected message always follows its observed turn/start */
|
||||
if (state.openTurn !== undefined) state.attempt.turn = state.openTurn
|
||||
state.attempt.rejectedReason = event.data.reason
|
||||
if (event.data.reason === STALE_ROUND_REASON) state.attempt.stale = true
|
||||
}
|
||||
return
|
||||
case 'turn/end':
|
||||
if (state.attempt?.turn === event.data.turn) state.attempt.reason = event.data.reason
|
||||
/* v8 ignore next -- balanced live turns close the open turn just observed by this listener */
|
||||
if (state.openTurn === event.data.turn) state.openTurn = undefined
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
/** Fail closed unless the queued prompt still owns the exact live revision. */
|
||||
function validReservation(
|
||||
state: DriverState,
|
||||
content: ContentBlock[],
|
||||
source: GoalMessageSource,
|
||||
): boolean {
|
||||
const attempt = state.attempt
|
||||
const goal = currentGoal(state)
|
||||
return ctx.fiber.state === FiberState.ACTIVE
|
||||
&& !state.stopping && attempt !== undefined && attempt.phase === 'queued'
|
||||
&& !attempt.stale && sameQueued(content, source, attempt)
|
||||
&& goal !== undefined && goal.id === source.goalId && goal.revision === source.revision
|
||||
&& goal.phase === 'active' && goal.activation === 'armed'
|
||||
&& source.round === goal.roundsStarted + 1
|
||||
}
|
||||
|
||||
ctx.on('agent/prompt-submit', async (agent, content, source, next): Promise<PromptDecision> => {
|
||||
if (!isGoalRoundSource(source)) return next()
|
||||
const state = stateFor(agent)
|
||||
let valid = false
|
||||
try {
|
||||
valid = validReservation(state, content, source)
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`goal-session: admission check failed for agent "${agent.id}": ${renderThrown(error)}`)
|
||||
disarm(state)
|
||||
}
|
||||
if (!valid) {
|
||||
const attempt = state.attempt
|
||||
if (attempt !== undefined && sameRound(source, attempt)) attempt.stale = true
|
||||
return { kind: 'block', reason: STALE_ROUND_REASON }
|
||||
}
|
||||
const decision = await next()
|
||||
if (decision.kind === 'block') return decision
|
||||
try {
|
||||
valid = validReservation(state, content, source)
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`goal-session: post-admission check failed for agent "${agent.id}": ${renderThrown(error)}`)
|
||||
disarm(state)
|
||||
valid = false
|
||||
}
|
||||
if (!valid) {
|
||||
const attempt = state.attempt
|
||||
if (attempt !== undefined && sameRound(source, attempt)) attempt.stale = true
|
||||
return { kind: 'block', reason: STALE_ROUND_REASON }
|
||||
}
|
||||
return decision
|
||||
})
|
||||
|
||||
// Loading a lifecycle driver over existing agents never inherits hidden
|
||||
// automatic authority from an earlier producer instance.
|
||||
for (const agent of ctx.agents.list()) {
|
||||
const state = stateFor(agent)
|
||||
disarm(state)
|
||||
}
|
||||
|
||||
// Yielded after listener registration, so this close runs first and the
|
||||
// composite effect removes listeners only after its promise settles.
|
||||
yield async () => {
|
||||
const waits: Promise<void>[] = []
|
||||
for (const state of states.values()) {
|
||||
state.stopping = true
|
||||
disarm(state)
|
||||
const attempt = state.attempt
|
||||
if (attempt !== undefined) {
|
||||
attempt.stale = true
|
||||
if (attempt.phase === 'admitted' && state.agent.status === 'running') {
|
||||
state.agent.cancel('goal-session driver disposed')
|
||||
}
|
||||
waits.push(state.agent.whenIdle())
|
||||
}
|
||||
if (state.run !== undefined) waits.push(state.run)
|
||||
}
|
||||
await Promise.allSettled(waits)
|
||||
states.clear()
|
||||
}
|
||||
}, 'goal-session lifecycle')
|
||||
}
|
||||
84
packages/goal/goal-session/src/invariant.ts
Normal file
84
packages/goal/goal-session/src/invariant.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/** Package-owned goal-round prompt invariants. @module @deepseek-ai/dsh-goal-session/invariant */
|
||||
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { Context } from 'cordis'
|
||||
import { foldGoal, type FoldedGoal, type GoalMessageSource, type GoalView } from '@deepseek-ai/dsh-goal'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { renderGoalRoundPrompt } from './prompt.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-goal-session'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'goal-session-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Attribute strict goal-fold failures to this companion's reconstruction. */
|
||||
function foldChecked(events: readonly SessionEvent[], fail: InvariantFailure): FoldedGoal {
|
||||
try {
|
||||
return foldGoal(events)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- the strict goal decoder throws Error instances */
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return fail(`cannot reconstruct the goal before a continuation message: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Recreate the live-shaped view consumed by the package's pure prompt renderer. */
|
||||
function goalView(folded: FoldedGoal, source: GoalMessageSource, fail: InvariantFailure): GoalView {
|
||||
const goal = folded.goal
|
||||
if (goal === undefined || folded.createdAt === undefined || folded.updatedAt === undefined
|
||||
|| goal.phase !== 'active' || goal.id !== source.goalId || goal.revision !== source.revision
|
||||
|| source.round !== folded.roundsStarted + 1 || source.round > goal.maxGoalRounds) {
|
||||
return fail(`goal round ${source.round} cannot be reconstructed from the preceding durable goal state`)
|
||||
}
|
||||
return {
|
||||
...goal,
|
||||
roundsStarted: folded.roundsStarted,
|
||||
createdAt: folded.createdAt,
|
||||
updatedAt: folded.updatedAt,
|
||||
activation: 'armed',
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one package-owned continuation message against its durable prefix. */
|
||||
function validateEvent(
|
||||
prior: readonly SessionEvent[],
|
||||
event: SessionEvent,
|
||||
fail: InvariantFailure,
|
||||
): void {
|
||||
if (event.type !== 'user/message') return
|
||||
const source = event.data.source
|
||||
if (source.kind !== 'goal' || source.round <= 0) return
|
||||
const expected = renderGoalRoundPrompt(goalView(foldChecked(prior, fail), source, fail), source.round)
|
||||
if (!isDeepStrictEqual(event.data.content, expected)) {
|
||||
fail(`goal round ${source.round} content does not match the package-owned continuation prompt`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Check existing sessions and every candidate event before Session publishes it. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const session of ctx.sessions.list()) {
|
||||
const prior: SessionEvent[] = []
|
||||
for (const event of session.events) {
|
||||
validateEvent(prior, event, fail)
|
||||
prior.push(event)
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-start -- package companions share dispatch and registration plumbing */
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
validateEvent(session.events, event, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
|
||||
/**
|
||||
* Register the goal-session invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
53
packages/goal/goal-session/src/outcome.ts
Normal file
53
packages/goal/goal-session/src/outcome.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/** Typed settlement policy for one admitted same-session goal round. */
|
||||
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Driver action derived from one closed goal-owned turn. */
|
||||
export type GoalRoundOutcome =
|
||||
| { readonly kind: 'continue' }
|
||||
| { readonly kind: 'pause'; readonly reason: string }
|
||||
| {
|
||||
readonly kind: 'blocked'
|
||||
readonly code: 'usage-limited' | 'turn-error' | 'max-tokens' | 'prompt-rejected' | 'unknown-turn-outcome'
|
||||
readonly message: string
|
||||
}
|
||||
| { readonly kind: 'disarm'; readonly reason: 'durability-failed' | 'disposed' | 'interrupted' }
|
||||
|
||||
/**
|
||||
* Classify one closed goal round without mutating goal state.
|
||||
* @param reason - durable reason from the round's `turn/end`.
|
||||
* @param durable - whether the closing flush reached its durability checkpoint.
|
||||
* @returns the single driver action; no abnormal outcome requests an automatic retry.
|
||||
*/
|
||||
export function classifyGoalRound(reason: TurnEndReason, durable: boolean): GoalRoundOutcome {
|
||||
if (!durable) return { kind: 'disarm', reason: 'durability-failed' }
|
||||
const extensibleReason: { readonly kind: string } = reason
|
||||
switch (reason.kind) {
|
||||
case 'completed':
|
||||
return { kind: 'continue' }
|
||||
case 'aborted':
|
||||
return { kind: 'pause', reason: reason.reason ?? 'cancelled' }
|
||||
case 'error': {
|
||||
const { code, message } = reason.failure ?? reason
|
||||
return code === 'RATE_LIMIT' || code === 'QUOTA'
|
||||
? { kind: 'blocked', code: 'usage-limited', message }
|
||||
: { kind: 'blocked', code: 'turn-error', message }
|
||||
}
|
||||
case 'max-tokens':
|
||||
return { kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' }
|
||||
case 'rejected':
|
||||
return { kind: 'blocked', code: 'prompt-rejected', message: reason.reason }
|
||||
case 'disposed':
|
||||
return { kind: 'disarm', reason: 'disposed' }
|
||||
case 'interrupted':
|
||||
return { kind: 'disarm', reason: 'interrupted' }
|
||||
// TurnEndReason is merge-extensible. An unknown producer cannot opt into
|
||||
// automatic retry merely by adding a tag; stop for inspection instead.
|
||||
default:
|
||||
return {
|
||||
kind: 'blocked',
|
||||
code: 'unknown-turn-outcome',
|
||||
message: `unknown turn outcome: ${extensibleReason.kind}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
26
packages/goal/goal-session/src/prompt.ts
Normal file
26
packages/goal/goal-session/src/prompt.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** Model-visible continuation prompt for one same-session goal round. */
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { GoalView } from '@deepseek-ai/dsh-goal'
|
||||
|
||||
/**
|
||||
* Render the complete goal-round instruction retained in session history.
|
||||
* @param goal - exact active goal revision being admitted.
|
||||
* @param round - next positive round number.
|
||||
* @returns a fresh one-block prompt for `Agent.send()`.
|
||||
*/
|
||||
export function renderGoalRoundPrompt(goal: GoalView, round: number): ContentBlock[] {
|
||||
return [{
|
||||
type: 'text',
|
||||
text: '<goal_round>\n'
|
||||
+ `Objective: ${JSON.stringify(goal.objective)}\n`
|
||||
+ `Round: ${round}/${goal.maxGoalRounds}\n\n`
|
||||
+ 'Continue working toward the objective in this same session. Treat the current workspace, '
|
||||
+ 'tool results, and durable session state as authoritative; inspect them instead of assuming '
|
||||
+ 'earlier narration is still current. Make concrete progress and verify the result. Before '
|
||||
+ 'claiming completion, gather evidence that the whole objective is achieved, read the current '
|
||||
+ 'goal, and mark it complete. If work remains, leave the goal active for the next round. Follow '
|
||||
+ 'the configured goal-tool policy before reporting a blocker.\n'
|
||||
+ '</goal_round>',
|
||||
}]
|
||||
}
|
||||
707
packages/goal/goal-session/tests/goal-session.spec.ts
Normal file
707
packages/goal/goal-session/tests/goal-session.spec.ts
Normal file
@@ -0,0 +1,707 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalView } from '@deepseek-ai/dsh-goal'
|
||||
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import * as goalSession from '../src/index.ts'
|
||||
|
||||
type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[])
|
||||
|
||||
/** Small request-recording adapter with controllable failure and cancellation. */
|
||||
class ScriptedAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
constructor(private readonly script: ScriptEntry[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const entry = this.script.shift()
|
||||
if (entry === undefined) throw new Error('ScriptedAdapter: script exhausted')
|
||||
if (entry instanceof Error) throw entry
|
||||
if (entry === 'hang') {
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: 'partial' }
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
reject(new Error('aborted'))
|
||||
return
|
||||
}
|
||||
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||
})
|
||||
return
|
||||
}
|
||||
const chunks = typeof entry === 'function' ? entry(options) : entry
|
||||
for (const chunk of chunks) yield chunk
|
||||
}
|
||||
}
|
||||
|
||||
/** One successful text response. */
|
||||
function textResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
}
|
||||
|
||||
/** One successful response cut off at the model output limit. */
|
||||
function maxTokensResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'finish', reason: { kind: 'max-tokens' } },
|
||||
]
|
||||
}
|
||||
|
||||
/** Complete request history as a single string for ordering assertions. */
|
||||
function requestText(request: GenerateOptions): string {
|
||||
return request.messages
|
||||
.flatMap(message => message.content)
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
readonly ctx: Context
|
||||
readonly adapter: ScriptedAdapter
|
||||
readonly agent: Agent
|
||||
readonly driver: Awaited<ReturnType<Context['plugin']>>
|
||||
}
|
||||
|
||||
const contexts: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.allSettled(contexts.splice(0).map(context => context.fiber.dispose()))
|
||||
})
|
||||
|
||||
/** Mount a real loop with only its model scripted. */
|
||||
async function harness(script: ScriptEntry[]): Promise<Harness> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(GoalService)
|
||||
const driver = await ctx.plugin(goalSession)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
const adapter = new ScriptedAdapter(script)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId(`goal-session-${Math.random()}`), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
return { ctx, adapter, agent, driver }
|
||||
}
|
||||
|
||||
/** Await a stable goal projection selected by the caller. */
|
||||
async function waitForGoal(
|
||||
ctx: Context,
|
||||
agent: Agent,
|
||||
predicate: (goal: GoalView | undefined) => boolean,
|
||||
): Promise<GoalView | undefined> {
|
||||
await vi.waitFor(() => {
|
||||
expect(predicate(ctx.goals.get(agent))).toBe(true)
|
||||
})
|
||||
return ctx.goals.get(agent)
|
||||
}
|
||||
|
||||
/** Await a specific number of dispatched model requests. */
|
||||
async function waitForRequests(adapter: ScriptedAdapter, count: number): Promise<void> {
|
||||
await vi.waitFor(() => {
|
||||
expect(adapter.requests).toHaveLength(count)
|
||||
})
|
||||
}
|
||||
|
||||
describe('goal-round outcome policy', () => {
|
||||
it.each([
|
||||
[{ kind: 'completed' }, true, { kind: 'continue' }],
|
||||
[{ kind: 'aborted', reason: 'operator stopped' }, true, { kind: 'pause', reason: 'operator stopped' }],
|
||||
[{ kind: 'aborted' }, true, { kind: 'pause', reason: 'cancelled' }],
|
||||
[{ kind: 'error', step: 1, message: 'slow down', code: 'RATE_LIMIT' }, true,
|
||||
{ kind: 'blocked', code: 'usage-limited', message: 'slow down' }],
|
||||
[{ kind: 'error', step: 1, failure: { message: 'credits exhausted', code: 'QUOTA' } }, true,
|
||||
{ kind: 'blocked', code: 'usage-limited', message: 'credits exhausted' }],
|
||||
[{ kind: 'error', step: 1, failure: { message: 'provider failed', code: 'SERVER' } }, true,
|
||||
{ kind: 'blocked', code: 'turn-error', message: 'provider failed' }],
|
||||
[{ kind: 'error', step: 1, message: 'broken' }, true,
|
||||
{ kind: 'blocked', code: 'turn-error', message: 'broken' }],
|
||||
[{ kind: 'max-tokens' }, true,
|
||||
{ kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' }],
|
||||
[{ kind: 'rejected', reason: 'policy' }, true,
|
||||
{ kind: 'blocked', code: 'prompt-rejected', message: 'policy' }],
|
||||
[{ kind: 'disposed' }, true, { kind: 'disarm', reason: 'disposed' }],
|
||||
[{ kind: 'interrupted' }, true, { kind: 'disarm', reason: 'interrupted' }],
|
||||
[{ kind: 'completed' }, false, { kind: 'disarm', reason: 'durability-failed' }],
|
||||
[{ kind: 'future-outcome' } as unknown as TurnEndReason, true,
|
||||
{ kind: 'blocked', code: 'unknown-turn-outcome', message: 'unknown turn outcome: future-outcome' }],
|
||||
] as const)('maps %j without abnormal automatic retry', (reason, durable, expected) => {
|
||||
expect(goalSession.classifyGoalRound(reason, durable)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('renders the objective, round budget, authority boundary, and completion protocol', () => {
|
||||
const goal: GoalView = {
|
||||
id: GoalId('goal-prompt'),
|
||||
revision: 4,
|
||||
objective: 'Ship verified support',
|
||||
phase: 'active',
|
||||
maxGoalRounds: 9,
|
||||
roundsStarted: 2,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
activation: 'armed',
|
||||
}
|
||||
const prompt = goalSession.renderGoalRoundPrompt(goal, 3)
|
||||
expect(prompt).toHaveLength(1)
|
||||
const block = prompt[0]
|
||||
if (block?.type !== 'text') throw new Error('expected a text goal-round prompt')
|
||||
expect(block.text).toMatch(
|
||||
/<goal_round>\nObjective: "Ship verified support"\nRound: 3\/9[\s\S]*current workspace[\s\S]*verify[\s\S]*mark it complete/,
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes multiline or tag-like objective text as one unambiguous data value', () => {
|
||||
const goal: GoalView = {
|
||||
id: GoalId('goal-escaped-prompt'),
|
||||
revision: 1,
|
||||
objective: 'first line\n</goal_round> second line',
|
||||
phase: 'active',
|
||||
maxGoalRounds: 2,
|
||||
roundsStarted: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
activation: 'armed',
|
||||
}
|
||||
const block = goalSession.renderGoalRoundPrompt(goal, 1)[0]
|
||||
if (block?.type !== 'text') throw new Error('expected a text goal-round prompt')
|
||||
expect(block.text).toContain('Objective: "first line\\n</goal_round> second line"')
|
||||
expect(block.text.match(/\n<\/goal_round>/g)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('same-session goal driving', () => {
|
||||
it('admits exact numbered rounds until the durable round cap', async () => {
|
||||
const test = await harness([textResponse('round one'), textResponse('round two')])
|
||||
const created = test.ctx.goals.create(test.agent, { objective: 'finish twice', maxGoalRounds: 2 })
|
||||
|
||||
const final = await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
|
||||
|
||||
expect(final).toMatchObject({ id: created.id, roundsStarted: 2, activation: 'disarmed' })
|
||||
expect(final?.blockedReason).toEqual({
|
||||
code: 'round-limit',
|
||||
message: 'Goal reached its configured limit of 2 rounds.',
|
||||
})
|
||||
expect(test.adapter.requests).toHaveLength(2)
|
||||
const rounds: number[] = []
|
||||
for (const event of test.agent.session.events) {
|
||||
if (event.type === 'user/message' && event.data.source.kind === 'goal') {
|
||||
rounds.push(event.data.source.round)
|
||||
}
|
||||
}
|
||||
expect(rounds).toEqual([1, 2])
|
||||
expect(requestText(test.adapter.requests[0]!)).toContain('Round: 1/2')
|
||||
expect(requestText(test.adapter.requests[1]!)).toContain('Round: 2/2')
|
||||
})
|
||||
|
||||
it('never adopts activation from an already-live driver and waits for explicit resume', async () => {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(GoalService)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
const adapter = new ScriptedAdapter([textResponse('after resume')])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('goal-session-hot-load'), { provider: 'mock', model: 'mock' })
|
||||
const created = ctx.goals.create(agent, { objective: 'wait for a human', maxGoalRounds: 1 })
|
||||
|
||||
await ctx.plugin(goalSession)
|
||||
await Promise.resolve()
|
||||
expect(ctx.goals.get(agent)).toMatchObject({ phase: 'active', activation: 'disarmed', revision: 1 })
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
|
||||
ctx.goals.resume(agent, created)
|
||||
await waitForGoal(ctx, agent, goal => goal?.phase === 'blocked')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['rate limit', new LlmError('slow down', 'RATE_LIMIT'), 'usage-limited'],
|
||||
['request error', new Error('provider broke'), 'turn-error'],
|
||||
['max tokens', maxTokensResponse('unfinished'), 'max-tokens'],
|
||||
] as const)('stops after a %s without an automatic retry', async (_label, response, code) => {
|
||||
const test = await harness([response])
|
||||
test.ctx.goals.create(test.agent, { objective: 'stop safely', maxGoalRounds: 8 })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
|
||||
|
||||
expect(goal).toMatchObject({ roundsStarted: 1, activation: 'disarmed' })
|
||||
expect(goal?.blockedReason?.code).toBe(code)
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('maps a downstream prompt veto to blocked without admitting the round', async () => {
|
||||
const test = await harness([])
|
||||
test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => source.kind === 'goal'
|
||||
? Promise.resolve({ kind: 'block', reason: 'deployment policy' })
|
||||
: next())
|
||||
test.ctx.goals.create(test.agent, { objective: 'respect policy' })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
|
||||
|
||||
expect(goal?.roundsStarted).toBe(0)
|
||||
expect(goal?.blockedReason).toEqual({ code: 'prompt-rejected', message: 'deployment policy' })
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
expect(test.agent.session.events.some(event => event.type === 'prompt/blocked'
|
||||
&& event.data.reason === 'deployment policy')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not reserve again when a stopped-goal observer queues ordinary work', async () => {
|
||||
const test = await harness([textResponse('human follow-up')])
|
||||
test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => source.kind === 'goal'
|
||||
? Promise.resolve({ kind: 'block', reason: 'stop this round' })
|
||||
: next())
|
||||
test.ctx.on('goal/changed', (agent, change) => {
|
||||
if (change.operation === 'block') agent.send([{ type: 'text', text: 'inspect the blocker' }])
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'stop and inspect' })
|
||||
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
|
||||
await waitForRequests(test.adapter, 1)
|
||||
await test.agent.whenIdle()
|
||||
|
||||
expect(requestText(test.adapter.requests[0]!)).toContain('inspect the blocker')
|
||||
})
|
||||
|
||||
it('pauses and drops a reserved round when cancellation lands before admission', async () => {
|
||||
const test = await harness([])
|
||||
const cancel = test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
if (agent === test.agent && info.source.kind === 'goal') {
|
||||
cancel()
|
||||
agent.cancel('operator cancelled pending goal')
|
||||
}
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'do not start yet' })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused')
|
||||
|
||||
expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' })
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
expect(test.agent.session.events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'goal')).toBe(false)
|
||||
})
|
||||
|
||||
it('pauses an admitted round when cancellation aborts an active step', async () => {
|
||||
const test = await harness(['hang'])
|
||||
test.ctx.goals.create(test.agent, { objective: 'stop in flight' })
|
||||
await waitForRequests(test.adapter, 1)
|
||||
|
||||
test.agent.cancel('operator stopped active goal')
|
||||
await test.agent.whenIdle()
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused')
|
||||
|
||||
expect(goal).toMatchObject({ roundsStarted: 1, activation: 'disarmed' })
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lets already-queued human work finish before reserving the next round', async () => {
|
||||
const test = await harness([textResponse('human answer'), textResponse('goal answer')])
|
||||
test.ctx.goals.create(test.agent, { objective: 'continue after the human', maxGoalRounds: 1 })
|
||||
test.agent.send([{ type: 'text', text: 'human goes first' }])
|
||||
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
|
||||
|
||||
expect(test.adapter.requests).toHaveLength(2)
|
||||
expect(requestText(test.adapter.requests[0]!)).toContain('human goes first')
|
||||
expect(requestText(test.adapter.requests[0]!)).not.toContain('<goal_round>')
|
||||
expect(requestText(test.adapter.requests[1]!)).toContain('<goal_round>')
|
||||
})
|
||||
|
||||
it('makes a reserved round stale when a listener queues human work behind it', async () => {
|
||||
const test = await harness([textResponse('human batch'), textResponse('later goal')])
|
||||
let inserted = false
|
||||
test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return
|
||||
inserted = true
|
||||
agent.send([{ type: 'text', text: 'human joined the pending batch' }])
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 })
|
||||
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
|
||||
|
||||
expect(test.adapter.requests).toHaveLength(2)
|
||||
expect(requestText(test.adapter.requests[0]!)).toContain('human joined the pending batch')
|
||||
expect(requestText(test.adapter.requests[0]!)).not.toContain('<goal_round>')
|
||||
expect(requestText(test.adapter.requests[1]!)).toContain('<goal_round>')
|
||||
})
|
||||
|
||||
it('blocks a queued reservation made stale by a goal edit and continues the new revision', async () => {
|
||||
const test = await harness([textResponse('new revision')])
|
||||
let edited = false
|
||||
test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || edited) return
|
||||
edited = true
|
||||
const current = test.ctx.goals.get(agent)
|
||||
if (current === undefined) throw new Error('missing goal during queued edit')
|
||||
test.ctx.goals.edit(agent, current, { objective: 'new objective' })
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'old objective', maxGoalRounds: 1 })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
|
||||
|
||||
expect(goal).toMatchObject({ revision: 3, objective: 'new objective', roundsStarted: 1 })
|
||||
const blocked = test.agent.session.events.find(event => event.type === 'prompt/blocked')
|
||||
expect(blocked?.type === 'prompt/blocked' ? blocked.data.reason : undefined)
|
||||
.toBe('stale goal-round reservation')
|
||||
const admitted = test.agent.session.events.find(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'goal')
|
||||
expect(admitted?.type === 'user/message' && admitted.data.source.kind === 'goal'
|
||||
? admitted.data.source.revision
|
||||
: undefined).toBe(2)
|
||||
})
|
||||
|
||||
it('rechecks revision after downstream prompt hooks before admitting', async () => {
|
||||
const test = await harness([textResponse('new revision')])
|
||||
let edited = false
|
||||
test.ctx.on('agent/prompt-submit', (agent, _content, source, next) => {
|
||||
if (source.kind === 'goal' && !edited) {
|
||||
edited = true
|
||||
const current = test.ctx.goals.get(agent)
|
||||
if (current === undefined) throw new Error('missing goal during prompt edit')
|
||||
test.ctx.goals.edit(agent, current, { objective: 'edited downstream' })
|
||||
}
|
||||
return next()
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'edit during admission', maxGoalRounds: 1 })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
|
||||
|
||||
expect(goal).toMatchObject({ objective: 'edited downstream', roundsStarted: 1 })
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
expect(test.agent.session.events.some(event => event.type === 'prompt/blocked'
|
||||
&& event.data.reason === 'stale goal-round reservation')).toBe(true)
|
||||
})
|
||||
|
||||
it('disarms without dispatch when a durability checkpoint fails', async () => {
|
||||
const test = await harness([])
|
||||
test.ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable')))
|
||||
test.ctx.goals.create(test.agent, { objective: 'do not outrun storage' })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed')
|
||||
|
||||
expect(goal).toMatchObject({ phase: 'active', roundsStarted: 0 })
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('contains a checkpoint failure after a clear notification leaves no current goal', async () => {
|
||||
const test = await harness([])
|
||||
test.ctx.on('session/flush', () => Promise.reject(new Error('clear checkpoint failed')))
|
||||
agentEvents(test.ctx, test.agent).emit('goal/changed', {
|
||||
operation: 'clear',
|
||||
ref: { id: GoalId('cleared-goal'), revision: 2 },
|
||||
})
|
||||
await new Promise<void>((resolve) => { setImmediate(resolve) })
|
||||
|
||||
expect(test.ctx.goals.get(test.agent)).toBeUndefined()
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('disarms an admitted round when a later injection hides its failed closing checkpoint', async () => {
|
||||
const test = await harness([textResponse('not durable')])
|
||||
let injected = false
|
||||
test.ctx.on('session/flush', (session) => {
|
||||
const lastStart = session.events.findLast(event => event.type === 'turn/start')
|
||||
if (lastStart?.type === 'turn/start' && lastStart.data.trigger.kind === 'message'
|
||||
&& lastStart.data.trigger.source.kind === 'goal' && !injected) {
|
||||
injected = true
|
||||
test.agent.inject([{ type: 'text', text: 'concurrent completion notice' }], {
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
return Promise.reject(new Error('round flush failed'))
|
||||
}
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'checkpoint the result' })
|
||||
|
||||
const goal = await waitForGoal(
|
||||
test.ctx,
|
||||
test.agent,
|
||||
current => current?.roundsStarted === 1 && current.activation === 'disarmed',
|
||||
)
|
||||
|
||||
expect(goal?.phase).toBe('active')
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
const turns = test.agent.session.events.filter(event => event.type === 'turn/start')
|
||||
const goalTurn = turns.findIndex(event => event.data.trigger.source.kind === 'goal')
|
||||
const injectedTurn = turns.findIndex(event => event.data.trigger.source.kind === 'plugin')
|
||||
expect(injectedTurn).toBeGreaterThan(goalTurn)
|
||||
})
|
||||
|
||||
it('blocks the goal when a custom agent rejects the otherwise valid send', async () => {
|
||||
const test = await harness([])
|
||||
vi.spyOn(test.agent, 'send').mockImplementationOnce(() => {
|
||||
throw new Error('queue rejected')
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'handle queue failure' })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
|
||||
|
||||
expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' })
|
||||
expect(goal?.blockedReason).toEqual({
|
||||
code: 'queue-failed',
|
||||
message: 'Could not queue goal round 1: queue rejected',
|
||||
})
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('preserves a custom agent side effect when send disarms before throwing', async () => {
|
||||
const test = await harness([])
|
||||
vi.spyOn(test.agent, 'send').mockImplementationOnce(() => {
|
||||
test.ctx.goals.disarm(test.agent)
|
||||
throw new Error('queue rejected after disarm')
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed')
|
||||
|
||||
expect(goal).toMatchObject({ phase: 'active', roundsStarted: 0 })
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('contains a driver read failure and removes continuation authority', async () => {
|
||||
const test = await harness([])
|
||||
let flushes = 0
|
||||
test.ctx.on('session/flush', () => {
|
||||
flushes += 1
|
||||
if (flushes !== 2) return
|
||||
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
|
||||
throw new Error('corrupt projection')
|
||||
})
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'fail the driver closed' })
|
||||
await new Promise<void>((resolve) => { setImmediate(resolve) })
|
||||
|
||||
const goal = test.ctx.goals.get(test.agent)
|
||||
|
||||
expect(goal?.phase).toBe('active')
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('contains synchronous scheduler startup failure', async () => {
|
||||
const test = await harness([])
|
||||
vi.spyOn(test.ctx.agents, 'withoutInitiator').mockImplementationOnce(() => {
|
||||
throw 'scheduler closed'
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'fail startup closed' })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed')
|
||||
|
||||
expect(goal?.phase).toBe('active')
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('contains an asynchronously rejected scheduler task', async () => {
|
||||
const test = await harness([])
|
||||
vi.spyOn(test.ctx.agents, 'withoutInitiator').mockImplementationOnce(
|
||||
() => Promise.reject(new Error('scheduler task rejected')),
|
||||
)
|
||||
test.ctx.goals.create(test.agent, { objective: 'fail task closed' })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed')
|
||||
|
||||
expect(goal?.phase).toBe('active')
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fails a pre-admission read closed even when the first disarm attempt throws', async () => {
|
||||
const test = await harness([textResponse('retry after containment')])
|
||||
let armed = true
|
||||
test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || !armed) return
|
||||
armed = false
|
||||
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
|
||||
throw new Error('admission projection failed')
|
||||
})
|
||||
vi.spyOn(test.ctx.goals, 'disarm').mockImplementationOnce(() => {
|
||||
throw 'disarm failed'
|
||||
})
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'retry stale admission', maxGoalRounds: 1 })
|
||||
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
|
||||
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
expect(test.agent.session.events.some(event => event.type === 'prompt/blocked'
|
||||
&& event.data.reason === 'stale goal-round reservation')).toBe(true)
|
||||
})
|
||||
|
||||
it('fails a post-hook read closed before the prompt can enter history', async () => {
|
||||
const test = await harness([])
|
||||
let armed = true
|
||||
test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => {
|
||||
if (source.kind === 'goal' && armed) {
|
||||
armed = false
|
||||
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
|
||||
throw new Error('post-hook projection failed')
|
||||
})
|
||||
}
|
||||
return next()
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'block post-hook failure' })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed')
|
||||
|
||||
expect(goal).toMatchObject({ phase: 'active', roundsStarted: 0 })
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('blocks forged goal attribution without touching an absent reservation', async () => {
|
||||
const test = await harness([])
|
||||
test.agent.send([{ type: 'text', text: 'forged automatic work' }], {
|
||||
source: { kind: 'goal', goalId: GoalId('forged-goal'), revision: 1, round: 1 },
|
||||
})
|
||||
await test.agent.whenIdle()
|
||||
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
expect(test.agent.session.events.some(event => event.type === 'prompt/blocked'
|
||||
&& event.data.reason === 'stale goal-round reservation')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not invent goal state when ordinary queued work is cancelled', async () => {
|
||||
const test = await harness([])
|
||||
test.agent.send([{ type: 'text', text: 'cancel ordinary work' }])
|
||||
test.agent.cancel('ordinary cancellation')
|
||||
await test.agent.whenIdle()
|
||||
|
||||
expect(test.ctx.goals.get(test.agent)).toBeUndefined()
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('disarms without durably pausing when cancellation belongs to unrelated human work', async () => {
|
||||
const test = await harness(['hang'])
|
||||
test.agent.send([{ type: 'text', text: 'inspect something first' }])
|
||||
await waitForRequests(test.adapter, 1)
|
||||
const created = test.ctx.goals.create(test.agent, { objective: 'continue after inspection' })
|
||||
|
||||
test.agent.cancel('cancel the inspection')
|
||||
await test.agent.whenIdle()
|
||||
|
||||
expect(test.ctx.goals.get(test.agent)).toMatchObject({
|
||||
id: created.id,
|
||||
revision: created.revision,
|
||||
phase: 'active',
|
||||
activation: 'disarmed',
|
||||
roundsStarted: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to disarming when a cancelled reservation cannot be paused', async () => {
|
||||
const test = await harness([])
|
||||
const cancel = test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal') return
|
||||
cancel()
|
||||
vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => {
|
||||
throw new Error('pause failed')
|
||||
})
|
||||
agent.cancel('cancel the reserved goal round')
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'fail closed after cancellation' })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed')
|
||||
|
||||
expect(goal).toMatchObject({ phase: 'active', revision: 1, roundsStarted: 0 })
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('blocks admission when downstream cancellation clears the reservation', async () => {
|
||||
const test = await harness([])
|
||||
let cancelled = false
|
||||
test.ctx.on('agent/prompt-submit', (agent, _content, source, next) => {
|
||||
if (source.kind === 'goal' && !cancelled) {
|
||||
cancelled = true
|
||||
agent.cancel('cancel from downstream admission policy')
|
||||
}
|
||||
return next()
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'cancel during admission' })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused')
|
||||
await test.agent.whenIdle()
|
||||
|
||||
expect(goal?.roundsStarted).toBe(0)
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('disarms and cancels an admitted round before driver teardown completes', async () => {
|
||||
const test = await harness(['hang'])
|
||||
test.ctx.goals.create(test.agent, { objective: 'survive plugin unload' })
|
||||
await waitForRequests(test.adapter, 1)
|
||||
|
||||
await test.driver.dispose()
|
||||
|
||||
expect(test.ctx.goals.get(test.agent)).toMatchObject({
|
||||
phase: 'active',
|
||||
activation: 'disarmed',
|
||||
roundsStarted: 1,
|
||||
})
|
||||
await test.agent.whenIdle()
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('cancels an accepted queued round and awaits its driver task during teardown', async () => {
|
||||
const test = await harness([])
|
||||
let unloading: Promise<void> | undefined
|
||||
test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
if (agent === test.agent && info.source.kind === 'goal' && unloading === undefined) {
|
||||
unloading = Promise.resolve(test.driver.dispose())
|
||||
}
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'unload while queued' })
|
||||
await vi.waitFor(() => { expect(unloading).toBeDefined() })
|
||||
await unloading
|
||||
|
||||
expect(test.ctx.goals.get(test.agent)).toMatchObject({
|
||||
phase: 'active',
|
||||
activation: 'disarmed',
|
||||
roundsStarted: 1,
|
||||
})
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('resets process-local scheduling state at a session-start edge', async () => {
|
||||
const test = await harness([textResponse('after explicit resume')])
|
||||
const created = test.ctx.goals.create(test.agent, { objective: 'restart safely', maxGoalRounds: 1 })
|
||||
agentEvents(test.ctx, test.agent).emit('agent/session-start', 'resume')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(test.ctx.goals.get(test.agent)).toMatchObject({ activation: 'disarmed', roundsStarted: 0 })
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
|
||||
test.ctx.goals.resume(test.agent, created)
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores session events without an exact owning agent and retires disposed agent state', async () => {
|
||||
const test = await harness([])
|
||||
const orphan = test.ctx.sessions.create(SessionId('goal-session-orphan'))
|
||||
orphan.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } },
|
||||
})
|
||||
orphan.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const handle = await test.ctx.agents.create({
|
||||
sessionId: SessionId('goal-session-disposed'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
await handle.dispose()
|
||||
|
||||
expect(test.ctx.agents.get(handle.agent.id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
143
packages/goal/goal-session/tests/invariant.spec.ts
Normal file
143
packages/goal/goal-session/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import {
|
||||
GoalId,
|
||||
renderGoalChange,
|
||||
type GoalSnapshotChangeMeta,
|
||||
type GoalView,
|
||||
} from '@deepseek-ai/dsh-goal'
|
||||
import * as GoalSessionInvariant from '@deepseek-ai/dsh-goal-session/invariant'
|
||||
import { renderGoalRoundPrompt } from '@deepseek-ai/dsh-goal-session'
|
||||
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
|
||||
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const change: GoalSnapshotChangeMeta = {
|
||||
kind: 'goal/change',
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
goal: {
|
||||
id: GoalId('goal-session-invariant'),
|
||||
revision: 1,
|
||||
objective: 'verify every continuation prompt',
|
||||
phase: 'active',
|
||||
maxGoalRounds: 2,
|
||||
},
|
||||
roundsStarted: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
}
|
||||
|
||||
const changeSource = {
|
||||
kind: 'goal',
|
||||
goalId: change.goal.id,
|
||||
revision: change.goal.revision,
|
||||
round: 0,
|
||||
} as const
|
||||
|
||||
function view(roundsStarted: number): GoalView {
|
||||
return { ...change.goal, roundsStarted, createdAt: 1, updatedAt: 1, activation: 'armed' }
|
||||
}
|
||||
|
||||
function appendChange(session: Session): void {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('context/message', {
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
meta: change as never,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
function appendRound(session: Session, turn: number, content = renderGoalRoundPrompt(view(turn - 2), turn - 1)): void {
|
||||
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: turn - 1 } as const
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
|
||||
session.append('user/message', { content, source }, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
async function mount(sessionFirst = false): Promise<{ ctx: Context; session: Session }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('goal-session-invariant'))
|
||||
if (!sessionFirst) {
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(GoalSessionInvariant)
|
||||
}
|
||||
return { ctx, session }
|
||||
}
|
||||
|
||||
describe('goal-session prompt invariants', () => {
|
||||
it('reconstructs existing rounds and accepts the next canonical prompt', async () => {
|
||||
const { ctx, session } = await mount(true)
|
||||
appendChange(session)
|
||||
appendRound(session, 2)
|
||||
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(GoalSessionInvariant)
|
||||
|
||||
expect(() => { appendRound(session, 3) }).not.toThrow()
|
||||
ctx.sessions.create(SessionId('goal-session-invariant-dispatch'))
|
||||
|
||||
const userSource = { kind: 'user' } as const
|
||||
session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: userSource } })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'ordinary human message' }],
|
||||
source: userSource,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 4, reason: { kind: 'completed' } })
|
||||
|
||||
const stateSource = { ...changeSource, round: 0 } as const
|
||||
session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: stateSource } })
|
||||
expect(() => {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'round zero is not a driver continuation' }],
|
||||
source: stateSource,
|
||||
}, { surfaceOp: 'append' })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a continuation whose content differs from the package renderer', async () => {
|
||||
const { session } = await mount()
|
||||
appendChange(session)
|
||||
|
||||
expect(() => {
|
||||
appendRound(session, 2, [{ type: 'text', text: 'counterfeit continuation' }])
|
||||
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-goal-session',
|
||||
}))
|
||||
})
|
||||
|
||||
it('rejects a goal round without a reconstructable active goal', async () => {
|
||||
const { session } = await mount()
|
||||
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 } as const
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } })
|
||||
|
||||
expect(() => {
|
||||
session.append('user/message', {
|
||||
content: renderGoalRoundPrompt(view(0), 1),
|
||||
source,
|
||||
}, { surfaceOp: 'append' })
|
||||
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
|
||||
packageName: '@deepseek-ai/dsh-goal-session',
|
||||
}))
|
||||
})
|
||||
|
||||
it('attributes an invalid durable prefix during late loading', async () => {
|
||||
const { ctx, session } = await mount(true)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'counterfeit goal state' }],
|
||||
source: changeSource,
|
||||
meta: change as never,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
appendRound(session, 2)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(GoalSessionInvariant)).rejects.toMatchObject({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-goal-session',
|
||||
})
|
||||
})
|
||||
})
|
||||
33
packages/goal/goal-session/tsconfig.json
Normal file
33
packages/goal/goal-session/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../goal"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,7 +15,7 @@ Event-sourced same-session goal state. The service retains one current completio
|
||||
|
||||
## Service contract
|
||||
|
||||
`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). Creation default resolution is an internal implementation step, not an additional public verb.
|
||||
`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). Creation default resolution is internal. `disarm()` is the lifecycle-only exception: it removes process-local continuation authority without writing a revision or emitting a mutation.
|
||||
|
||||
At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation.
|
||||
|
||||
@@ -23,7 +23,7 @@ Every non-clear mutation appends a complete versioned snapshot through `agent.in
|
||||
|
||||
Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained.
|
||||
|
||||
Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. Session resume and fork therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation.
|
||||
Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. A continuation driver also calls `disarm()` before unload or after durability uncertainty. Session resume, fork, and driver replacement therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation.
|
||||
|
||||
The separately published `./invariant` companion maintains an independent fold of each attached session. It rejects malformed goal metadata, source or model-visible content drift, discontinuous revisions, illegal lifecycle transitions, timestamp regressions, and non-sequential admitted rounds before the candidate event enters the durable log.
|
||||
|
||||
|
||||
@@ -165,6 +165,21 @@ export class GoalService extends Service {
|
||||
return this.view(cache)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove process-local continuation authority without changing durable goal
|
||||
* phase or revision. Lifecycle owners use this before unloading a driver;
|
||||
* a later human-authorized {@link resume} records the new activation edge.
|
||||
* @param agent - owning live agent.
|
||||
* @returns a fresh disarmed view, or `undefined` when no goal is current.
|
||||
*/
|
||||
disarm(agent: Agent): GoalView | undefined {
|
||||
this.assertLive(agent)
|
||||
const cache = this.cache(agent.session)
|
||||
this.sync(agent.session, cache)
|
||||
cache.activation = 'disarmed'
|
||||
return this.view(cache)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and arm a goal. A completed goal may be replaced; every other
|
||||
* current phase must be cleared or resumed instead.
|
||||
|
||||
@@ -231,6 +231,20 @@ describe('GoalService creation and replay', () => {
|
||||
expect(() => foldGoal(session.events)).not.toThrow()
|
||||
})
|
||||
|
||||
it('lets a lifecycle owner disarm without writing a durable revision', async () => {
|
||||
const { ctx, agent, session } = await harness()
|
||||
const goal = ctx.goals.create(agent, { objective: 'survive driver reload' })
|
||||
const before = session.events.length
|
||||
expect(ctx.goals.disarm(agent)).toMatchObject({
|
||||
id: goal.id,
|
||||
revision: goal.revision,
|
||||
phase: 'active',
|
||||
activation: 'disarmed',
|
||||
})
|
||||
expect(session.events).toHaveLength(before)
|
||||
expect(ctx.goals.resume(agent, goal)).toMatchObject({ revision: 2, activation: 'armed' })
|
||||
})
|
||||
|
||||
it('removes the service and its session-start listener with the providing fiber', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
|
||||
76
packages/goal/tool-goal/README.md
Normal file
76
packages/goal/tool-goal/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# @deepseek-ai/dsh-tool-goal
|
||||
|
||||
The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal`, `create_goal`, and `update_goal`. The [goal-tool Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md) owns the authority split and Codex-shaped UX.
|
||||
|
||||
## Tools
|
||||
|
||||
- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation.
|
||||
- `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution.
|
||||
- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`.
|
||||
|
||||
All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations.
|
||||
|
||||
An autonomous goal round that successfully reports `complete` or `blocked` contributes the existing terminal `agent/turn-stop` decision for that physical turn. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop.
|
||||
|
||||
## Authority
|
||||
|
||||
Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does.
|
||||
|
||||
`{ kind: 'user' }` is a host attestation. `Agent.send()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority.
|
||||
|
||||
Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted and must describe it in `blocked_reason`. Direct human authority may stop a goal immediately.
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
- id: tool-goal
|
||||
name: '@deepseek-ai/dsh-tool-goal'
|
||||
config:
|
||||
blockedAfterConsecutiveRounds: 3
|
||||
```
|
||||
|
||||
The value must be a positive safe integer. It supplies both the hard lower bound on model self-blocking and the number named in model guidance.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A fixed goal policy says when semantic human intent warrants creation, requires exact read-before-update refs, explains rearming after resume/fork, and limits completion/blocking claims. The configured threshold is interpolated into that guidance.
|
||||
|
||||
##### Goal policy
|
||||
|
||||
```markdown
|
||||
Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Small fixed input cost on every request where this plugin's prompt registration is in scope.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the plugin scope, configured threshold, and guidance text are unchanged. Activation, disposal, or configuration changes may invalidate reuse from this prompt section.
|
||||
|
||||
### Tool schemas and results
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `<goal_state>` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Schemas are prefix-stable while their definitions and visibility are unchanged. Calls, results, and resulting goal snapshots append after the reusable request prefix without invalidating earlier entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Semantic intent remains model judgment** — execution can prove direct human provenance, not whether a request is substantial enough to merit a goal.
|
||||
- **Same-condition blocking remains model judgment** — the runtime enforces distinct admitted-round count, not semantic equivalence of obstacles; an independent evaluator is deferred.
|
||||
- **No scheduling or direct human rendering** — these tools mutate state only; the same-session driver and [`dsh-command-goal`](../command-goal/README.md) are independent consumers of the same domain.
|
||||
- **Goal-round authority requires a driver** — the autonomous `complete`/`blocked` path is dormant unless a continuation driver admits goal-sourced user turns; mounting this tool package alone does not create them.
|
||||
- **Prompt registration is independent of filtering** — a scope may hide the tools while retaining their guidance unless the deployment scopes both registrations together.
|
||||
53
packages/goal/tool-goal/package.json
Normal file
53
packages/goal/tool-goal/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-goal",
|
||||
"description": "Model-facing same-session goal tools with execution-time authority checks",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
109
packages/goal/tool-goal/src/authority.ts
Normal file
109
packages/goal/tool-goal/src/authority.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/** Execution-time authority checks for the model-facing goal tools. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { GoalView } from '@deepseek-ai/dsh-goal'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
type TurnStartEvent = Extract<SessionEvent, { type: 'turn/start' }>
|
||||
|
||||
/** Current open turn plus the events accepted after its start boundary. */
|
||||
export interface GoalToolExecution {
|
||||
readonly agent: Agent
|
||||
readonly start: TurnStartEvent
|
||||
readonly events: readonly SessionEvent[]
|
||||
}
|
||||
|
||||
/** Hard authority granted to one state-changing call. */
|
||||
export type GoalToolAuthority =
|
||||
| { readonly kind: 'direct-human' }
|
||||
| { readonly kind: 'goal-round'; readonly goal: GoalView }
|
||||
|
||||
/** Throw one structured tool-policy failure. */
|
||||
function reject(message: string, code = 'GOAL_TOOL_AUTHORITY_REQUIRED'): never {
|
||||
throw new HarnessError(message, code)
|
||||
}
|
||||
|
||||
/** Locate the open turn enclosing a model tool call. */
|
||||
function openTurn(agent: Agent): { start: TurnStartEvent; events: readonly SessionEvent[] } {
|
||||
const events = agent.session.events
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const boundary = events[index]
|
||||
if (boundary?.type === 'turn/end') {
|
||||
reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED')
|
||||
}
|
||||
if (boundary?.type === 'turn/start') {
|
||||
return { start: boundary, events: events.slice(index + 1) }
|
||||
}
|
||||
}
|
||||
return reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and authenticate the calling agent and its driver boundary.
|
||||
* @param ctx - Context carrying the live agent registry.
|
||||
* @param exec - Tool execution metadata supplied by the registry.
|
||||
* @returns The authenticated agent and its current turn window.
|
||||
*/
|
||||
export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolExecution {
|
||||
const agent = exec.agent
|
||||
if (agent === undefined) {
|
||||
return reject('goal tools require a calling agent', 'GOAL_TOOL_AGENT_REQUIRED')
|
||||
}
|
||||
if (ctx.agents.get(agent.id) !== agent || agent.status !== 'running'
|
||||
|| ctx.agents.currentInitiator() !== agent) {
|
||||
return reject(
|
||||
'goal tools require the exact live calling agent inside its active driver',
|
||||
'GOAL_TOOL_DRIVER_REQUIRED',
|
||||
)
|
||||
}
|
||||
return { agent, ...openTurn(agent) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether host-attested human input appears in the current root-agent turn.
|
||||
* An omitted `Agent.send()` / `steer()` source resolves to `user`, so non-human
|
||||
* producers must supply their own source rather than inheriting this authority.
|
||||
*/
|
||||
function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean {
|
||||
if (!ctx.agents.roots().includes(execution.agent)) return false
|
||||
return execution.events.some(event =>
|
||||
(event.type === 'user/message' || event.type === 'steering/message')
|
||||
&& event.data.source.kind === 'user')
|
||||
}
|
||||
|
||||
/** Whether this turn is the current goal's exact admitted round. */
|
||||
function isMatchingGoalRound(execution: GoalToolExecution, goal: GoalView): boolean {
|
||||
return execution.events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'goal'
|
||||
&& event.data.source.goalId === goal.id
|
||||
&& event.data.source.revision === goal.revision
|
||||
&& event.data.source.round === goal.roundsStarted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Require authority originating in a human message accepted by a runtime root.
|
||||
* @param ctx - Context carrying the live agent graph.
|
||||
* @param execution - Authenticated current tool execution.
|
||||
*/
|
||||
export function requireDirectHuman(ctx: Context, execution: GoalToolExecution): void {
|
||||
if (hasDirectHumanInput(ctx, execution)) return
|
||||
reject('this goal operation requires a direct human turn on a top-level agent')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve completion authority from either direct human input or the exact goal round.
|
||||
* @param ctx - Context carrying live agents and goal state.
|
||||
* @param execution - Authenticated current tool execution.
|
||||
* @returns The direct-human or exact-goal-round authority grant.
|
||||
*/
|
||||
export function completionAuthority(ctx: Context, execution: GoalToolExecution): GoalToolAuthority {
|
||||
if (hasDirectHumanInput(ctx, execution)) return { kind: 'direct-human' }
|
||||
const goal = ctx.goals.get(execution.agent)
|
||||
if (goal !== undefined && isMatchingGoalRound(execution, goal)) {
|
||||
return { kind: 'goal-round', goal }
|
||||
}
|
||||
return reject('complete and blocked require a direct human turn or the current goal round')
|
||||
}
|
||||
276
packages/goal/tool-goal/src/index.ts
Normal file
276
packages/goal/tool-goal/src/index.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Model-facing `get_goal`, `create_goal`, and `update_goal` tools over the
|
||||
* persisted same-session goal domain.
|
||||
* @module @deepseek-ai/dsh-tool-goal
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
completionAuthority,
|
||||
goalToolExecution,
|
||||
requireDirectHuman,
|
||||
} from './authority.ts'
|
||||
import type { GoalToolExecution } from './authority.ts'
|
||||
|
||||
export const name = 'tool-goal'
|
||||
export const inject = ['agents', 'goals', 'tools', 'systemPrompt']
|
||||
|
||||
/** Model policy and hard lower bounds for goal-state updates. */
|
||||
export interface Config {
|
||||
/** Minimum admitted goal rounds before the model may self-report `blocked`. */
|
||||
blockedAfterConsecutiveRounds?: number
|
||||
}
|
||||
|
||||
/** Schemastery config for the goal-tool policy. */
|
||||
export const Config: z<Config> = z.object({
|
||||
blockedAfterConsecutiveRounds: z.number().step(1).min(1).default(3),
|
||||
})
|
||||
|
||||
/** Fully materialized tool policy. */
|
||||
interface ResolvedConfig {
|
||||
readonly blockedAfterConsecutiveRounds: number
|
||||
}
|
||||
|
||||
type UpdateAction = 'edit' | 'pause' | 'resume' | 'complete' | 'blocked'
|
||||
|
||||
const UPDATE_ACTIONS: UpdateAction[] = ['edit', 'pause', 'resume', 'complete', 'blocked']
|
||||
|
||||
const CREATE_DESCRIPTION =
|
||||
'Create one persisted same-session completion goal when the current direct human request '
|
||||
+ 'is a long-running objective that should continue across autonomous goal rounds. You may '
|
||||
+ 'infer that intent without requiring the user to say "create a goal". Do not use this for '
|
||||
+ 'trivial single-turn work. Execution rejects non-human and subagent authority.'
|
||||
|
||||
const GET_DESCRIPTION =
|
||||
'Read the current same-session goal, including its exact id/revision, objective, phase, completed '
|
||||
+ 'continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. '
|
||||
+ 'Call this before updating a goal.'
|
||||
|
||||
/** Render policy guidance with its deployment-selected blocked threshold. */
|
||||
function guidance(blockedAfter: number): string {
|
||||
return 'Use goal tools for one long-running completion objective in the current session. '
|
||||
+ 'create_goal may infer goal intent from a direct human request in any language; do not '
|
||||
+ 'create a goal for routine single-turn work. Call get_goal before update_goal and copy its '
|
||||
+ 'exact goal_id and revision. After session resume or fork, an active goal is disarmed: when '
|
||||
+ 'a human asks to continue or resume in any wording or language, use update_goal action '
|
||||
+ 'resume to rearm it. Mark complete only when the objective is actually achieved. Mark '
|
||||
+ `blocked only after the same blocking condition persists for at least ${blockedAfter} `
|
||||
+ 'consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, '
|
||||
+ 'or useful remaining work is not blocked.'
|
||||
}
|
||||
|
||||
/** Validate config even when apply is called directly outside Loader normalization. */
|
||||
function resolveConfig(config: Config): ResolvedConfig {
|
||||
const blockedAfter = config.blockedAfterConsecutiveRounds ?? 3
|
||||
if (!Number.isSafeInteger(blockedAfter) || blockedAfter < 1) {
|
||||
throw new TypeError('blockedAfterConsecutiveRounds must be a positive safe integer')
|
||||
}
|
||||
return { blockedAfterConsecutiveRounds: blockedAfter }
|
||||
}
|
||||
|
||||
/** Build the exact compare-and-set ref from model arguments. */
|
||||
function goalRef(goalId: string, revision: number): GoalRef {
|
||||
if (goalId.length === 0 || goalId !== goalId.trim()
|
||||
|| !Number.isSafeInteger(revision) || revision < 1) {
|
||||
throw new HarnessError(
|
||||
'goal_id must be non-empty and revision must be a positive safe integer',
|
||||
'GOAL_TOOL_INVALID_UPDATE',
|
||||
)
|
||||
}
|
||||
return { id: GoalId(goalId), revision }
|
||||
}
|
||||
|
||||
/** Stable compact model result; activation is an observation, not replay state. */
|
||||
function renderGoal(goal: GoalView | undefined): string {
|
||||
if (goal === undefined) return JSON.stringify({ goal: null })
|
||||
return JSON.stringify({
|
||||
goal: {
|
||||
id: goal.id,
|
||||
revision: goal.revision,
|
||||
objective: goal.objective,
|
||||
phase: goal.phase,
|
||||
roundsStarted: goal.roundsStarted,
|
||||
maxGoalRounds: goal.maxGoalRounds,
|
||||
...goal.blockedReason === undefined ? {} : { blockedReason: goal.blockedReason },
|
||||
},
|
||||
activation: goal.activation,
|
||||
})
|
||||
}
|
||||
|
||||
/** Generic, args-only pending presentation shared by the goal tools. */
|
||||
function present(title: string, kind: 'read' | 'other', rawInput?: unknown): GenericCallView {
|
||||
return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } }
|
||||
}
|
||||
|
||||
/** Remember whether one autonomous terminal report should stop this turn. */
|
||||
function observeMutation(
|
||||
terminalTurns: WeakMap<Agent, number>,
|
||||
execution: GoalToolExecution,
|
||||
autonomousTerminal: boolean,
|
||||
): void {
|
||||
if (!autonomousTerminal) {
|
||||
terminalTurns.delete(execution.agent)
|
||||
return
|
||||
}
|
||||
terminalTurns.set(execution.agent, execution.start.data.turn)
|
||||
}
|
||||
|
||||
/** Register the three Codex-shaped goal tools and their shared policy section. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved = resolveConfig(config)
|
||||
// A stale entry cannot match a later loop turn because turn numbers increase
|
||||
// monotonically within the agent's fixed session.
|
||||
const terminalTurns = new WeakMap<Agent, number>()
|
||||
ctx.on('agent/turn-stop', (agent, turn) => {
|
||||
if (terminalTurns.get(agent) !== turn) return undefined
|
||||
terminalTurns.delete(agent)
|
||||
return { action: 'stop' }
|
||||
})
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:goal',
|
||||
order: 114,
|
||||
text: guidance(resolved.blockedAfterConsecutiveRounds),
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'get_goal',
|
||||
description: GET_DESCRIPTION,
|
||||
parameters: {},
|
||||
execute(_args, exec) {
|
||||
const execution = goalToolExecution(ctx, exec)
|
||||
return Promise.resolve([{
|
||||
type: 'text',
|
||||
text: renderGoal(ctx.goals.get(execution.agent)),
|
||||
}])
|
||||
},
|
||||
presentCall: () => present('Read current goal', 'read'),
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'create_goal',
|
||||
description: CREATE_DESCRIPTION,
|
||||
parameters: {
|
||||
objective: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The concrete completion objective inferred from the direct human request.',
|
||||
},
|
||||
max_goal_rounds: {
|
||||
type: 'number',
|
||||
description: 'Optional positive safe-integer limit on automatic continuation rounds.',
|
||||
},
|
||||
},
|
||||
execute(args, exec) {
|
||||
const execution = goalToolExecution(ctx, exec)
|
||||
requireDirectHuman(ctx, execution)
|
||||
const goal = ctx.goals.create(execution.agent, {
|
||||
objective: args.objective,
|
||||
...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds },
|
||||
})
|
||||
observeMutation(terminalTurns, execution, false)
|
||||
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
|
||||
},
|
||||
presentCall: args => present('Create goal', 'other', args.objective),
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'update_goal',
|
||||
description: 'Update the exact current goal revision. edit, pause, and resume require a direct '
|
||||
+ 'top-level human request. During an automatic continuation of the current goal, complete '
|
||||
+ 'and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains '
|
||||
+ 'responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.',
|
||||
parameters: {
|
||||
goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' },
|
||||
revision: { type: 'number', required: true, description: 'Exact positive revision returned by get_goal.' },
|
||||
action: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
enum: UPDATE_ACTIONS,
|
||||
description: 'edit | pause | resume | complete | blocked',
|
||||
},
|
||||
objective: { type: 'string', description: 'Replacement objective; valid only with action edit.' },
|
||||
max_goal_rounds: { type: 'number', description: 'Replacement cap; valid only with action edit.' },
|
||||
blocked_reason: {
|
||||
type: 'string',
|
||||
description: 'Concrete blocking condition; required only with action blocked.',
|
||||
},
|
||||
},
|
||||
execute(args, exec) {
|
||||
const execution = goalToolExecution(ctx, exec)
|
||||
const ref = goalRef(args.goal_id, args.revision)
|
||||
const replacements = {
|
||||
...args.objective === undefined ? {} : { objective: args.objective },
|
||||
...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds },
|
||||
}
|
||||
if (args.action === 'edit') {
|
||||
requireDirectHuman(ctx, execution)
|
||||
if (args.blocked_reason !== undefined) {
|
||||
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
|
||||
}
|
||||
const goal = ctx.goals.edit(execution.agent, ref, replacements)
|
||||
observeMutation(terminalTurns, execution, false)
|
||||
return Promise.resolve([{
|
||||
type: 'text',
|
||||
text: renderGoal(goal),
|
||||
}])
|
||||
}
|
||||
if (args.action === 'pause' || args.action === 'resume') {
|
||||
requireDirectHuman(ctx, execution)
|
||||
if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) {
|
||||
throw new HarnessError(
|
||||
'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked',
|
||||
'GOAL_TOOL_INVALID_UPDATE',
|
||||
)
|
||||
}
|
||||
const goal = args.action === 'pause'
|
||||
? ctx.goals.pause(execution.agent, ref)
|
||||
: ctx.goals.resume(execution.agent, ref)
|
||||
observeMutation(terminalTurns, execution, false)
|
||||
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
|
||||
}
|
||||
const authority = completionAuthority(ctx, execution)
|
||||
if (args.objective !== undefined || args.max_goal_rounds !== undefined) {
|
||||
throw new HarnessError(
|
||||
'objective and max_goal_rounds are valid only with action edit',
|
||||
'GOAL_TOOL_INVALID_UPDATE',
|
||||
)
|
||||
}
|
||||
if (args.action === 'complete' && args.blocked_reason !== undefined) {
|
||||
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
|
||||
}
|
||||
if (args.action === 'blocked'
|
||||
&& (args.blocked_reason === undefined || args.blocked_reason.trim().length === 0)) {
|
||||
throw new HarnessError('blocked_reason is required with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
|
||||
}
|
||||
if (args.action === 'blocked' && authority.kind === 'goal-round'
|
||||
&& authority.goal.roundsStarted < resolved.blockedAfterConsecutiveRounds) {
|
||||
throw new HarnessError(
|
||||
`blocked requires at least ${resolved.blockedAfterConsecutiveRounds} consecutive goal rounds; `
|
||||
+ `current round is ${authority.goal.roundsStarted}`,
|
||||
'GOAL_TOOL_BLOCK_THRESHOLD',
|
||||
)
|
||||
}
|
||||
const goal = args.action === 'complete'
|
||||
? ctx.goals.complete(execution.agent, ref)
|
||||
: ctx.goals.block(execution.agent, ref, {
|
||||
code: 'model-reported',
|
||||
message: args.blocked_reason as string,
|
||||
})
|
||||
observeMutation(terminalTurns, execution, authority.kind === 'goal-round')
|
||||
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
|
||||
},
|
||||
presentCall: args => present(
|
||||
`${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`,
|
||||
'other',
|
||||
args.blocked_reason ?? args.objective ?? args.goal_id,
|
||||
),
|
||||
}))
|
||||
}
|
||||
30
packages/goal/tool-goal/src/invariant.ts
Normal file
30
packages/goal/tool-goal/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-goal`.
|
||||
* @module @deepseek-ai/dsh-tool-goal/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-goal'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-goal-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this model-facing adapter owns no independent state or event protocol;
|
||||
* accepted mutations are checked by the goal domain and authority behavior is package-tested.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
486
packages/goal/tool-goal/tests/tool-goal.spec.ts
Normal file
486
packages/goal/tool-goal/tests/tool-goal.spec.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
|
||||
import GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef } from '@deepseek-ai/dsh-goal'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import * as toolGoal from '@deepseek-ai/dsh-tool-goal'
|
||||
|
||||
interface StubAgent {
|
||||
readonly agent: Agent
|
||||
readonly session: Session
|
||||
setStatus(status: AgentStatus): void
|
||||
}
|
||||
|
||||
/** Build one registry-compatible live agent whose injections append in place. */
|
||||
function stubAgent(rawId: string, supplied?: Session): StubAgent {
|
||||
const session = supplied ?? new Session(SessionId(rawId))
|
||||
let status: AgentStatus = 'running'
|
||||
const agent: Agent = {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
get status() { return status },
|
||||
ctx: new Context(),
|
||||
send() {},
|
||||
steer() {},
|
||||
inject(content: ContentBlock[], options?: InjectOptions) {
|
||||
const source = options?.source ?? { kind: 'user' }
|
||||
session.append('context/message', {
|
||||
content,
|
||||
source,
|
||||
...options?.meta === undefined ? {} : { meta: options.meta },
|
||||
}, { surfaceOp: 'append' })
|
||||
},
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
return { agent, session, setStatus(value) { status = value } }
|
||||
}
|
||||
|
||||
/** Open one message-triggered turn with its accepted model-visible input. */
|
||||
function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): number {
|
||||
const turn = stub.session.events
|
||||
.filter(event => event.type === 'turn/start')
|
||||
.reduce((max, event) => Math.max(max, event.data.turn), 0) + 1
|
||||
stub.session.append('turn/start', { turn, trigger: { kind: 'message', source } })
|
||||
stub.session.append('user/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source,
|
||||
}, { surfaceOp: 'append' })
|
||||
return turn
|
||||
}
|
||||
|
||||
/** Close the currently open test turn. */
|
||||
function closeTurn(stub: StubAgent, turn: number): void {
|
||||
stub.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
async function harness(config: toolGoal.Config = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(GoalService)
|
||||
const fiber = await ctx.plugin(toolGoal, config)
|
||||
const root = stubAgent(`goal-tool-root-${Math.random()}`)
|
||||
ctx.agents.register(root.agent)
|
||||
return { ctx, fiber, root }
|
||||
}
|
||||
|
||||
/** Execute one registered tool under an optional driver initiator. */
|
||||
async function execute(
|
||||
ctx: Context,
|
||||
name: string,
|
||||
args: unknown,
|
||||
agent?: Agent,
|
||||
initiator: Agent | undefined = agent,
|
||||
): Promise<ToolExecutionResult> {
|
||||
const run = () => ctx.tools.execute({
|
||||
callId: CallId(`call-${Math.random()}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...agent === undefined ? {} : { agent },
|
||||
})
|
||||
return initiator === undefined ? run() : ctx.agents.withInitiator(initiator, run)
|
||||
}
|
||||
|
||||
/** Parse the compact JSON returned by a successful goal tool. */
|
||||
function resultJson(result: ToolExecutionResult): Record<string, unknown> {
|
||||
expect(result.isError).toBe(false)
|
||||
const block = result.content[0]
|
||||
if (block?.type !== 'text') throw new Error('expected text tool result')
|
||||
return JSON.parse(block.text) as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Read the returned goal sub-object. */
|
||||
function resultGoal(result: ToolExecutionResult): Record<string, unknown> {
|
||||
const goal = resultJson(result)['goal']
|
||||
if (typeof goal !== 'object' || goal === null) throw new Error('expected returned goal')
|
||||
return goal as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('goal tool registration and presentation', () => {
|
||||
it('registers three exclusive tools plus configured guidance and disposes all contributions', async () => {
|
||||
const { ctx, fiber } = await harness({ blockedAfterConsecutiveRounds: 5 })
|
||||
expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name))
|
||||
.toEqual(['create_goal', 'get_goal', 'update_goal'])
|
||||
for (const name of ['create_goal', 'get_goal', 'update_goal']) {
|
||||
expect(ctx.tools.executionMode({ callId: CallId(name), name, arguments: {} }))
|
||||
.toEqual({ kind: 'exclusive' })
|
||||
}
|
||||
const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal')
|
||||
expect(section?.text).toContain('infer goal intent')
|
||||
expect(section?.text).toContain('at least 5 consecutive goal rounds')
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.get('get_goal')).toBeUndefined()
|
||||
expect((await ctx.systemPrompt.assemble()).sections.some(item => item.name === 'tool:goal')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses args-only generic render intent and soft-fails malformed replay args', async () => {
|
||||
const { ctx } = await harness()
|
||||
expect(ctx.tools.get('get_goal')?.presentCall?.({})).toEqual({
|
||||
card: 'generic', title: 'Read current goal', kind: 'read',
|
||||
})
|
||||
expect(ctx.tools.get('create_goal')?.presentCall?.({ objective: 'ship' })).toEqual({
|
||||
card: 'generic', title: 'Create goal', kind: 'other', rawInput: 'ship',
|
||||
})
|
||||
expect(ctx.tools.get('update_goal')?.presentCall?.({
|
||||
goal_id: 'goal-1', revision: 2, action: 'blocked', blocked_reason: 'Waiting for a human choice.',
|
||||
})).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'Waiting for a human choice.' })
|
||||
expect(ctx.tools.get('update_goal')?.presentCall?.({
|
||||
goal_id: 'goal-1', revision: 2, action: 'resume',
|
||||
})).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' })
|
||||
expect(ctx.tools.get('update_goal')?.presentCall?.({ wrong: true })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('has the Loader-safe namespace export shape', () => {
|
||||
expect('default' in toolGoal).toBe(false)
|
||||
expect(toolGoal.name).toBe('tool-goal')
|
||||
expect(toolGoal.inject).toEqual(['agents', 'goals', 'tools', 'systemPrompt'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
expect(loader.unwrapExports(toolGoal)).toBe(toolGoal)
|
||||
})
|
||||
|
||||
it('fails invalid direct config before registering anything', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(GoalService)
|
||||
expect(() => {
|
||||
toolGoal.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 })
|
||||
}).toThrow(
|
||||
'blockedAfterConsecutiveRounds must be a positive safe integer',
|
||||
)
|
||||
expect(ctx.tools.get('get_goal')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves the direct-apply default before registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(GoalService)
|
||||
toolGoal.apply(ctx, {})
|
||||
const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal')
|
||||
expect(section?.text).toContain('at least 3 consecutive goal rounds')
|
||||
})
|
||||
})
|
||||
|
||||
describe('goal tool execution authority', () => {
|
||||
it('lets a root model infer create intent from its accepted human turn', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'user' }, '请持续工作直到这个功能完成')
|
||||
const result = await execute(ctx, 'create_goal', {
|
||||
objective: 'Finish the feature', max_goal_rounds: 9,
|
||||
}, root.agent)
|
||||
expect(resultGoal(result)).toMatchObject({
|
||||
objective: 'Finish the feature', revision: 1, phase: 'active', maxGoalRounds: 9,
|
||||
})
|
||||
expect(resultJson(result)['activation']).toBe('armed')
|
||||
expect(ctx.goals.get(root.agent)?.objective).toBe('Finish the feature')
|
||||
})
|
||||
|
||||
it('rejects agentless, driverless, non-human, and live-child creation', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const agentless = await execute(ctx, 'get_goal', {})
|
||||
expect(agentless.error?.code).toBe('GOAL_TOOL_AGENT_REQUIRED')
|
||||
|
||||
openTurn(root, { kind: 'user' })
|
||||
const driverless = await ctx.tools.execute({
|
||||
callId: CallId('call-driverless'),
|
||||
name: 'get_goal',
|
||||
arguments: {},
|
||||
agent: root.agent,
|
||||
})
|
||||
expect(driverless.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
closeTurn(root, 1)
|
||||
|
||||
openTurn(root, { kind: 'plugin', plugin: 'test' })
|
||||
const nonHuman = await execute(ctx, 'create_goal', { objective: 'forged' }, root.agent)
|
||||
expect(nonHuman.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
closeTurn(root, 2)
|
||||
|
||||
const child = stubAgent('goal-tool-child')
|
||||
ctx.agents.enter(child.agent, root.agent)
|
||||
ctx.agents.announce(child.agent)
|
||||
openTurn(child, { kind: 'user' })
|
||||
const childResult = await execute(ctx, 'create_goal', { objective: 'child goal' }, child.agent)
|
||||
expect(childResult.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
})
|
||||
|
||||
it('rejects stale agent objects and agents outside running status through the executor', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'user' })
|
||||
const stale = { ...root.agent }
|
||||
const staleResult = await execute(ctx, 'get_goal', {}, stale, stale)
|
||||
expect(staleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
|
||||
root.setStatus('idle')
|
||||
const idleResult = await execute(ctx, 'get_goal', {}, root.agent)
|
||||
expect(idleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
})
|
||||
|
||||
it('treats a fork resumed as a runtime root as direct-human authority', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const originalTurn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'resume the fork' })
|
||||
closeTurn(root, originalTurn)
|
||||
const forkId = SessionId('goal-tool-resumed-fork')
|
||||
const forkSession = new Session(forkId, root.session.events, {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: forkId,
|
||||
createdAt: Date.now(),
|
||||
parentSession: root.session.id,
|
||||
seedLength: root.session.seq,
|
||||
})
|
||||
const fork = stubAgent(forkId, forkSession)
|
||||
ctx.agents.register(fork.agent)
|
||||
expect(ctx.goals.get(fork.agent)).toMatchObject({ id: created.id, activation: 'disarmed' })
|
||||
|
||||
openTurn(fork, { kind: 'user' }, '继续这个目标')
|
||||
const resumed = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'resume',
|
||||
}, fork.agent)
|
||||
expect(resultGoal(resumed)).toMatchObject({ id: created.id, revision: 2, phase: 'active' })
|
||||
})
|
||||
|
||||
it('rejects calls before a turn and after its end boundary', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const before = await execute(ctx, 'get_goal', {}, root.agent)
|
||||
expect(before.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
|
||||
const turn = openTurn(root, { kind: 'user' })
|
||||
closeTurn(root, turn)
|
||||
const after = await execute(ctx, 'get_goal', {}, root.agent)
|
||||
expect(after.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
})
|
||||
|
||||
it('rejects terminal reporting without human input or a current goal round', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'plugin', plugin: 'test' })
|
||||
const result = await execute(ctx, 'update_goal', {
|
||||
goal_id: 'goal-missing', revision: 1, action: 'complete',
|
||||
}, root.agent)
|
||||
expect(result.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
const malformed = await execute(ctx, 'update_goal', {
|
||||
goal_id: 'goal-missing', revision: 1, action: 'pause', objective: 'probe',
|
||||
}, root.agent)
|
||||
expect(malformed.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
})
|
||||
|
||||
it('accepts direct human steering in a goal-sourced root turn', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const humanTurn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'steer me' })
|
||||
closeTurn(root, humanTurn)
|
||||
const round = openTurn(root, {
|
||||
kind: 'goal', goalId: created.id, revision: created.revision, round: 1,
|
||||
})
|
||||
root.session.append('steering/message', {
|
||||
turn: round,
|
||||
content: [{ type: 'text', text: 'pause now' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const paused = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'pause',
|
||||
}, root.agent)
|
||||
expect(resultGoal(paused)).toMatchObject({ phase: 'paused', revision: 2 })
|
||||
})
|
||||
|
||||
it('rejects an initiator different from exec.agent', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const other = stubAgent('goal-tool-other')
|
||||
ctx.agents.register(other.agent)
|
||||
openTurn(other, { kind: 'user' })
|
||||
const result = await execute(ctx, 'get_goal', {}, other.agent, root.agent)
|
||||
expect(result.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
})
|
||||
})
|
||||
|
||||
describe('goal tool state transitions', () => {
|
||||
it('reads null, then edits, pauses, and resumes by exact revision in one human turn', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'user' })
|
||||
expect(resultJson(await execute(ctx, 'get_goal', {}, root.agent))).toEqual({ goal: null })
|
||||
let goal = resultGoal(await execute(ctx, 'create_goal', { objective: 'old' }, root.agent))
|
||||
goal = resultGoal(await execute(ctx, 'update_goal', {
|
||||
goal_id: goal['id'], revision: goal['revision'], action: 'edit',
|
||||
objective: 'new', max_goal_rounds: 8,
|
||||
}, root.agent))
|
||||
expect(goal).toMatchObject({ objective: 'new', revision: 2, maxGoalRounds: 8 })
|
||||
goal = resultGoal(await execute(ctx, 'update_goal', {
|
||||
goal_id: goal['id'], revision: goal['revision'], action: 'pause',
|
||||
}, root.agent))
|
||||
expect(goal).toMatchObject({ phase: 'paused', revision: 3 })
|
||||
goal = resultGoal(await execute(ctx, 'update_goal', {
|
||||
goal_id: goal['id'], revision: goal['revision'], action: 'resume',
|
||||
}, root.agent))
|
||||
expect(goal).toMatchObject({ phase: 'active', revision: 4 })
|
||||
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const humanTurn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'pause cleanly' })
|
||||
const paused = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'pause',
|
||||
}, root.agent)
|
||||
expect(resultGoal(paused)).toMatchObject({ phase: 'paused' })
|
||||
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', humanTurn)).toBeUndefined()
|
||||
const resumed = resultGoal(await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: 2, action: 'resume',
|
||||
}, root.agent))
|
||||
closeTurn(root, humanTurn)
|
||||
|
||||
const roundTurn = openTurn(root, {
|
||||
kind: 'goal', goalId: created.id, revision: resumed['revision'] as number, round: 1,
|
||||
})
|
||||
const complete = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: resumed['revision'], action: 'complete',
|
||||
}, root.agent)
|
||||
expect(resultGoal(complete)).toMatchObject({ phase: 'complete' })
|
||||
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn)).toEqual({ action: 'stop' })
|
||||
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rearms a restored active goal only after a new direct human prompt', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
let turn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'continue later' })
|
||||
closeTurn(root, turn)
|
||||
agentEvents(ctx, root.agent).emit('agent/session-start', 'resume')
|
||||
expect(ctx.goals.get(root.agent)?.activation).toBe('disarmed')
|
||||
turn = openTurn(root, { kind: 'user' }, '继续')
|
||||
const resumed = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'resume',
|
||||
}, root.agent)
|
||||
expect(resultGoal(resumed)).toMatchObject({ phase: 'active', revision: 2 })
|
||||
expect(resultJson(resumed)['activation']).toBe('armed')
|
||||
closeTurn(root, turn)
|
||||
})
|
||||
|
||||
it('returns structured domain and conditional-argument failures', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'user' })
|
||||
const invalidCreate = await execute(ctx, 'create_goal', { objective: ' ' }, root.agent)
|
||||
expect(invalidCreate.error?.code).toBe('GOAL_INVALID_OBJECTIVE')
|
||||
const created = ctx.goals.create(root.agent, { objective: 'valid' })
|
||||
const replacement = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id,
|
||||
revision: created.revision,
|
||||
action: 'pause',
|
||||
objective: 'not valid for pause',
|
||||
}, root.agent)
|
||||
expect(replacement.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const terminalUpdate = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id,
|
||||
revision: created.revision,
|
||||
action: 'complete',
|
||||
max_goal_rounds: 2,
|
||||
}, root.agent)
|
||||
expect(terminalUpdate.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const blockedWithoutReason = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'blocked',
|
||||
}, root.agent)
|
||||
expect(blockedWithoutReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const blockedWithEmptyReason = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'blocked', blocked_reason: ' ',
|
||||
}, root.agent)
|
||||
expect(blockedWithEmptyReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const completeWithReason = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'complete', blocked_reason: 'Not a blocker.',
|
||||
}, root.agent)
|
||||
expect(completeWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const editWithReason = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id,
|
||||
revision: created.revision,
|
||||
action: 'edit',
|
||||
objective: 'still valid',
|
||||
blocked_reason: 'Not valid for edit.',
|
||||
}, root.agent)
|
||||
expect(editWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const malformedRef = await execute(ctx, 'update_goal', {
|
||||
goal_id: '', revision: 0, action: 'edit', objective: 'x',
|
||||
}, root.agent)
|
||||
expect(malformedRef.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
})
|
||||
|
||||
it('allows exact goal rounds to complete but not edit or pause', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const humanTurn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'round-owned' })
|
||||
closeTurn(root, humanTurn)
|
||||
openTurn(root, { kind: 'goal', goalId: created.id, revision: created.revision, round: 1 })
|
||||
const edit = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'edit', objective: 'forbidden',
|
||||
}, root.agent)
|
||||
expect(edit.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
const complete = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'complete',
|
||||
}, root.agent)
|
||||
expect(resultGoal(complete)).toMatchObject({ phase: 'complete', revision: 2, roundsStarted: 1 })
|
||||
})
|
||||
|
||||
it('enforces the configured model self-block lower bound across admitted rounds', async () => {
|
||||
const { ctx, root } = await harness({ blockedAfterConsecutiveRounds: 3 })
|
||||
let turn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'blocked eventually' })
|
||||
closeTurn(root, turn)
|
||||
const ref: GoalRef = { id: GoalId(created.id), revision: created.revision }
|
||||
|
||||
for (let round = 1; round <= 2; round += 1) {
|
||||
turn = openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round })
|
||||
const result = await execute(ctx, 'update_goal', {
|
||||
goal_id: ref.id,
|
||||
revision: ref.revision,
|
||||
action: 'blocked',
|
||||
blocked_reason: 'The required credential is still unavailable.',
|
||||
}, root.agent)
|
||||
expect(result.error?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD')
|
||||
closeTurn(root, turn)
|
||||
}
|
||||
openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 3 })
|
||||
const blocked = await execute(ctx, 'update_goal', {
|
||||
goal_id: ref.id,
|
||||
revision: ref.revision,
|
||||
action: 'blocked',
|
||||
blocked_reason: 'The required credential is still unavailable.',
|
||||
}, root.agent)
|
||||
expect(resultGoal(blocked)).toMatchObject({
|
||||
phase: 'blocked',
|
||||
blockedReason: { code: 'model-reported', message: 'The required credential is still unavailable.' },
|
||||
roundsStarted: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('lets direct human authority block before the model threshold', async () => {
|
||||
const { ctx, root } = await harness({ blockedAfterConsecutiveRounds: 9 })
|
||||
openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'human stop' })
|
||||
const blocked = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id,
|
||||
revision: created.revision,
|
||||
action: 'blocked',
|
||||
blocked_reason: 'The user asked to stop until a prerequisite is available.',
|
||||
}, root.agent)
|
||||
expect(resultGoal(blocked)).toMatchObject({
|
||||
phase: 'blocked',
|
||||
blockedReason: {
|
||||
code: 'model-reported',
|
||||
message: 'The user asked to stop until a prerequisite is available.',
|
||||
},
|
||||
roundsStarted: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
42
packages/goal/tool-goal/tsconfig.json
Normal file
42
packages/goal/tool-goal/tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../goal"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user