feat(goal): add human goal command

This commit is contained in:
Tianyi Cui
2026-07-19 23:55:33 +08:00
parent 400b4658ef
commit 207692bc16
114 changed files with 1831 additions and 88 deletions

View File

@@ -7,5 +7,6 @@ The goal family owns durable objective state independently of the model-facing t
| `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.

View 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); TUI and ACP discover and execute it without a model turn. The [human goal-command RFC](../../../docs/rfc/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; show usage when no goal exists. |
| `/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 direct command errors. 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 terminal 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 line-oriented stdio and JSON-RPC adapters do not consume `ctx.commands`. Their ordinary human prompts can still authorize the model-facing goal tools when those are composed.

View File

@@ -0,0 +1,38 @@
{
"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"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-goal": "^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-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,165 @@
/**
* 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 'usage-limited': return 'usage limited'
case 'budget-limited': return 'limited by round budget'
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':
case 'usage-limited':
return '/goal edit <objective>, /goal resume, /goal clear'
case 'budget-limited':
return '/goal edit <objective>, /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 {
return {
kind: 'success',
text: [
title,
`Status: ${phaseLabel(goal.phase)}`,
`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: error.message }
throw error
}
}
/** Register the Codex-shaped `/goal` human command on TUI and ACP surfaces. */
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),
})
}

View File

@@ -0,0 +1,254 @@
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?.envelope === undefined ? {} : { envelope: options.envelope },
...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,
'tui',
`/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 }
}
/** Append one admitted goal round for budget-limited presentation coverage. */
function appendRound(test: Harness, goal: NonNullable<ReturnType<GoalService['get']>>): void {
const source = { kind: 'goal', goalId: goal.id, revision: goal.revision, round: 1 } as const
const turn = nextTurn(test.session)
test.session.append('turn/start', { turn, trigger: { kind: 'message', source } })
test.session.append('user/message', {
content: [{ type: 'text', text: 'goal round' }],
source,
}, { surfaceOp: 'append' })
test.session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
describe('@deepseek-ai/dsh-command-goal registration', () => {
it('registers one global TUI/ACP 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, 'tui')).toContainEqual({
name: 'goal',
description: 'set or view the goal for a long-running task',
input: { hint: '[<objective>|clear|edit <objective>|pause|resume]' },
surfaces: ['tui', 'acp'],
})
expect(test.ctx.commands.find(test.agent, 'acp', 'goal')).toBeDefined()
await test.plugin.dispose()
expect(test.ctx.commands.find(test.agent, 'tui', '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.kind).toBe('error')
expect(redundantResume.text).toContain('already active and armed')
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))
expect((await run(test)).text).toContain('Status: blocked')
goal = test.ctx.goals.resume(test.agent, ref(goal))
goal = test.ctx.goals.markUsageLimited(test.agent, ref(goal))
expect((await run(test)).text).toContain('Status: usage limited')
goal = test.ctx.goals.resume(test.agent, ref(goal))
appendRound(test, goal)
goal = test.ctx.goals.get(test.agent)!
goal = test.ctx.goals.markBudgetLimited(test.agent, ref(goal))
const limited = await run(test)
expect(limited.text).toContain('Status: limited by round budget')
expect(limited.text).not.toContain('/goal resume')
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')
})
})

View File

@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../ui/commands"
},
{
"path": "../goal"
}
]
}

View File

@@ -69,5 +69,5 @@ Schemas are prefix-stable while their definitions and visibility are unchanged.
- **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 UI commands** — these tools mutate state only; the same-session driver and human command surfaces are separate stack layers.
- **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.
- **Prompt registration is independent of filtering** — a scope may hide the tools while retaining their guidance unless the deployment scopes both registrations together.