Merge origin/master into worktree-windows-runtime

# Conflicts:
#	.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md
#	packages/support/acp-snapshot/src/harness.ts
#	packages/support/acp-snapshot/src/normalize.ts
#	packages/support/acp-snapshot/tests/harness.spec.ts
#	packages/support/acp-snapshot/tests/normalize.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-21 17:14:44 +08:00
263 files changed
+12997 -276

No files matched your search

+2 -1
View File
@@ -9,6 +9,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| Group | Role | Release expectation |
|---|---|---|
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
@@ -19,7 +20,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface |
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, the worker-thread engine, and the model-facing `workflow` tool | Product — stable surface |
| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, worker-thread engine, and model-facing `workflow` and fresh-agent `ralph` tools | Product — stable surface |
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`spill/`](spill/README.md) | Spill capability family: the storage seam, a local impl, and the tool-result spill policy | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
@@ -525,6 +525,7 @@ describe('workspace context instruction discovery', () => {
vi.resetModules()
vi.doMock('node:os', () => ({ homedir: () => home }))
vi.stubEnv('DSH_HOME', undefined)
const isolated = await import('@deepseek-ai/dsh-workspace-context')
const files = await isolated.discoverBaselineInstructionFiles({ cwd: root })
@@ -532,6 +533,7 @@ describe('workspace context instruction discovery', () => {
} finally {
vi.doUnmock('node:os')
vi.resetModules()
vi.unstubAllEnvs()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
+142 -1
View File
@@ -198,6 +198,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'commands',
summary: 'Human-command registry.',
methods: [
{
signature: 'register(definition: CommandDefinition): () => void',
jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */',
},
{
signature: 'list(agent: Agent): readonly CommandDescriptor[]',
jsDoc: '/**\n * List the effective immutable command descriptors for one agent.\n * @param agent - exact receiving agent and scoped-layer key.\n * @returns name-sorted descriptors after scoped shadowing.\n */',
},
{
signature: 'find(agent: Agent, name: string): CommandDefinition | undefined',
jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition.\n */',
},
{
signature: 'async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<CommandResult | undefined>',
jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n * @param agent - exact receiving agent.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns a detached result, or `undefined` when syntax or name does not resolve.\n */',
},
],
},
{
key: 'compact',
summary: 'Abstract compaction service.',
@@ -250,6 +272,48 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'goals',
summary: 'Goal service (`ctx.goals`) backed exclusively by the owning session log.',
methods: [
{
signature: 'get(agent: Agent): GoalView | undefined',
jsDoc: '/**\n * Read the current goal for one exact live agent.\n * @param agent - owning live agent.\n * @returns a fresh view or `undefined` when no goal is current.\n * @throws {@link GoalError} when the agent is not the registry\'s live instance.\n */',
},
{
signature: 'disarm(agent: Agent): GoalView | undefined',
jsDoc: '/**\n * Remove process-local continuation authority without changing durable goal\n * phase or revision. Lifecycle owners use this before unloading a driver;\n * a later human-authorized {@link resume} records the new activation edge.\n * @param agent - owning live agent.\n * @returns a fresh disarmed view, or `undefined` when no goal is current.\n */',
},
{
signature: 'create(agent: Agent, request: CreateGoalRequest): GoalView',
jsDoc: '/**\n * Create and arm a goal. A completed goal may be replaced; every other\n * current phase must be cleared or resumed instead.\n * @param agent - owning live agent.\n * @param request - objective and optional round cap.\n * @returns the created live view.\n */',
},
{
signature: 'edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView',
jsDoc: '/**\n * Edit objective and/or round cap without changing phase.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param request - at least one replacement field.\n * @returns the edited view.\n */',
},
{
signature: 'pause(agent: Agent, ref: GoalRef): GoalView',
jsDoc: '/**\n * Pause an active goal and disarm automatic continuation.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the paused view.\n */',
},
{
signature: 'resume(agent: Agent, ref: GoalRef): GoalView',
jsDoc: '/**\n * Resume and arm a stopped goal, or rearm an active goal after a\n * session-start edge, while its round budget still has capacity.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the active view.\n */',
},
{
signature: 'complete(agent: Agent, ref: GoalRef): GoalView',
jsDoc: '/**\n * Mark a current non-complete goal complete and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the completed view.\n */',
},
{
signature: 'block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView',
jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param reason - policy-owned stable code and human-readable explanation.\n * @returns the blocked view with its durable reason.\n */',
},
{
signature: 'clear(agent: Agent, ref: GoalRef): GoalRef',
jsDoc: '/**\n * Clear the current goal while retaining a durable tombstone and history.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the tombstone ref whose revision is one past the cleared snapshot.\n */',
},
],
},
{
key: 'llm',
summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.',
@@ -636,6 +700,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param sessionId - exact shared agent/session identity that failed startup.\n * @param error - persistence, setup, or publication failure.\n * @mode emit\n */',
summary: 'A declarative agent entry failed before it could publish a live agent.',
},
{
name: 'agent/cancel-requested',
mode: 'emit',
signature: '\'agent/cancel-requested\'(this: Scoped<Agent>, agent: Agent, reason: string): void',
jsDoc: '/**\n * Effective broad cancellation was requested, before queued/steering work\n * is cleared or the active step is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param reason - resolved cancellation reason, including the default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'Effective broad cancellation was requested, before queued/steering work is cleared or the active step is aborted.',
},
{
name: 'agent/created',
mode: 'emit',
@@ -748,6 +819,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * Ask composed answerers for one decision. Return an outcome to claim the\n * request or call `next()`; failure yields the fail-closed default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param req - the pending decision (agent, tool identity, reason, signal).\n * @mode waterfall\n */',
summary: 'Ask composed answerers for one decision.',
},
{
name: 'commands/change',
mode: 'emit',
signature: '\'commands/change\'(): void',
jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */',
summary: 'A command was registered or unregistered.',
},
{
name: 'fs/edit-intent',
mode: 'waterfall',
@@ -769,6 +847,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * Single-slot decision for the next {@link FileSystem.writeText}. Calling\n * `next()` yields the bare provider\'s unconditional write; the first listener\n * that returns an intent owns the decision rather than composing with peers.\n * @param target - the resolved target about to be written.\n * @param actor - the opaque tool-execution context the decider keys off.\n * @mode waterfall\n */',
summary: 'Single-slot decision for the next FileSystem.writeText.',
},
{
name: 'goal/changed',
mode: 'emit',
signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped<Agent>, agent: Agent, change: GoalChanged): void',
jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching context event is\n * already appended or queued in that agent\'s active tool-batch FIFO.\n * Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - agent whose session owns the goal.\n * @param change - fresh current projection or clear tombstone.\n * @mode emit\n */',
summary: 'Goal mutation accepted by one live agent.',
},
{
name: 'llm/stream',
mode: 'waterfall',
@@ -1063,6 +1148,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'CollectedOutput',
declaration: 'export interface CollectedOutput {\n text: string;\n truncated: boolean;\n spillPath?: string;\n}',
},
{
name: 'CommandDefinition',
declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>;\n}',
},
{
name: 'CommandDescriptor',
declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n}',
},
{
name: 'CommandInputDescriptor',
declaration: 'export interface CommandInputDescriptor {\n readonly hint: string;\n}',
},
{
name: 'CommandInvocation',
declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}',
},
{
name: 'CommandResult',
declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};',
},
{
name: 'CompactAgentContext',
declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}',
@@ -1095,6 +1200,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'CreateAgentOptions',
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
},
{
name: 'CreateGoalRequest',
declaration: 'export interface CreateGoalRequest {\n readonly objective: string;\n readonly maxGoalRounds?: number;\n}',
},
{
name: 'CreateSessionOptions',
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}',
@@ -1115,6 +1224,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'DshEnvironmentKey',
declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;',
},
{
name: 'EditGoalRequest',
declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}',
},
{
name: 'EpochHeader',
declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n}',
@@ -1187,6 +1300,34 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'GenericResultView',
declaration: 'export interface GenericResultView {\n card: \'generic\';\n title?: string;\n content?: ContentBlock[];\n}',
},
{
name: 'GoalActivation',
declaration: 'export type GoalActivation = \'armed\' | \'disarmed\';',
},
{
name: 'GoalBlockReason',
declaration: 'export interface GoalBlockReason {\n readonly code: string;\n readonly message: string;\n}',
},
{
name: 'GoalId',
declaration: 'export type GoalId = Branded<\'GoalId\'>;',
},
{
name: 'GoalPhase',
declaration: 'export type GoalPhase = \'active\' | \'paused\' | \'blocked\' | \'complete\';',
},
{
name: 'GoalRef',
declaration: 'export interface GoalRef {\n readonly id: GoalId;\n readonly revision: number;\n}',
},
{
name: 'GoalSnapshot',
declaration: 'export interface GoalSnapshot extends GoalRef {\n readonly objective: string;\n readonly phase: GoalPhase;\n readonly blockedReason?: GoalBlockReason;\n readonly maxGoalRounds: number;\n}',
},
{
name: 'GoalView',
declaration: 'export interface GoalView extends GoalSnapshot {\n readonly roundsStarted: number;\n readonly createdAt: number;\n readonly updatedAt: number;\n readonly activation: GoalActivation;\n}',
},
{
name: 'HookContext',
declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n}',
@@ -1669,7 +1810,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'WorkflowStartRequest',
declaration: 'export interface WorkflowStartRequest {\n script: string;\n meta: WorkflowMeta;\n args?: unknown;\n parent: Agent;\n signal?: AbortSignal;\n}',
declaration: 'export interface WorkflowStartRequest {\n script: string;\n meta: WorkflowMeta;\n args?: unknown;\n subagentProvider?: string;\n maxTotalAgents?: number;\n parent: Agent;\n signal?: AbortSignal;\n}',
},
{
name: 'WorkflowStopReason',
+1 -1
View File
@@ -56,7 +56,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history.
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. Cancellation clears pending work and aborts the current step without leaking to the next prompt; undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush.
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. Effective cancellation resolves its reason and emits `agent/cancel-requested` before clearing pending work or aborting the current step; notification failures are contained, queued work added by an observer is included in the same broad clear, and idle cancellation emits nothing. Undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush.
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path.
+7 -2
View File
@@ -331,13 +331,18 @@ export class ReactLoopAgent implements Agent {
}
cancel(reason?: string): void {
const resolvedReason = reason ?? 'cancelled'
// Arm only for current work; an idle marker would cancel the next prompt.
if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) {
this.cancelRequested = true
// Capture the resolved reason for the marker-only windows (pre-step /
// continuation). The mid-step path reads it from abort.signal.reason
// below; the marker path reads it via the LoopHandle's cancelReason().
this.cancelReason = reason ?? 'cancelled'
this.cancelReason = resolvedReason
// Coordination consumers must update their own state before this call
// clears the inbox or aborts the step. Notification failures are
// contained by the fused dispatcher and cannot veto cancellation.
agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedReason)
}
// Drop all pending queued + steering work (un-started prompts never run; the
// cancelled turn's steering is not re-enqueued). Cleared directly even when
@@ -347,7 +352,7 @@ export class ReactLoopAgent implements Agent {
// Interrupt an in-flight step immediately (the running turn observes the
// abort and ends `aborted`). The marker covers the windows where no step is
// running (pre-step, continuation).
this.currentAbort?.abort(reason ?? 'cancelled')
this.currentAbort?.abort(resolvedReason)
}
/**
+28 -1
View File
@@ -7,7 +7,7 @@
* @module dsh-agent-loop/tests/cancel
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -55,6 +55,33 @@ function userTexts(agent: Agent): string[] {
}
describe('Agent.cancel()', () => {
it('notifies every observer before clearing work and contains listener failures', async () => {
const adapter = new MockAdapter([textResponse('must remain unused')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('cancel-event'), { provider: 'mock', model: 'mock' })
const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const seen: string[] = []
ctx.on('agent/cancel-requested', (subject, reason) => {
if (subject !== agent) return
seen.push(`first:${reason}`)
subject.send([{ type: 'text', text: 'queued by cancel observer' }])
throw new Error('observer failed')
})
ctx.on('agent/cancel-requested', (subject, reason) => {
if (subject === agent) seen.push(`second:${reason}`)
})
send(agent, 'drop me')
agent.cancel()
await new Promise(resolve => setTimeout(resolve, 30))
agent.cancel('idle no-op')
expect(seen).toEqual(['first:cancelled', 'second:cancelled'])
expect(userTexts(agent)).toEqual([])
expect(adapter.requests).toHaveLength(0)
expect(warned).toHaveBeenCalledWith(expect.stringContaining('agent/cancel-requested'))
})
it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
@@ -523,7 +523,7 @@ describe('steering from late extension points is never stranded', () => {
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing')
})
it('steer() from a step/end session-event listener forces a SAME-TURN next step (/goal pattern)', async () => {
it('steer() from a step/end session-event listener forces a SAME-TURN next step', async () => {
// Assert the same-turn shape; content alone cannot distinguish re-enqueue.
const adapter = new MockAdapter([
textResponse('no tools, would stop'),
+3 -3
View File
@@ -44,7 +44,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way).
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved reason, then clears queues and aborts; notification failures are contained and cannot veto the stop. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way).
`PromptDecision.additionalContexts` is an array so every injected context keeps its own source and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source.
@@ -54,10 +54,10 @@ Turn and step boundaries and the model token stream are durable `session/event`
The handle every plugin programs against:
- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the rationale.
- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Omitting `options.source` attests direct human input as `{ kind: 'user' }` and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the rationale.
- `agent.steer(content, options?)` — submit steering while the agent is `running`. An open turn records it at the next steering checkpoint before a request or continuation decision; policy can still stop before another step. After turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. The method uses the same synchronous snapshot-and-validation boundary as `send` and delegates to `send` when idle
- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)).
- `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op.
- `agent.cancel(reason?)` — cancel ALL pending work: an effective call emits `agent/cancel-requested` before it clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window). Observers may synchronize their own state but cannot veto cancellation. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op with no notification.
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
- `agent.session`, `agent.status`, `agent.options`, `agent.id`
+18 -5
View File
@@ -25,7 +25,10 @@ export interface AgentOptions {
model?: string
}
/** Message options; an omitted source resolves to `{ kind: 'user' }`, so plugins must label their own content. */
/**
* Message options. An omitted source attests direct human input as `{ kind: 'user' }`
* and may authorize policy consumers, so non-human producers must label their content.
*/
export interface SendOptions {
source?: MessageSource
}
@@ -122,10 +125,10 @@ export interface Agent {
/**
* Clear all queued and steering work, including items waiting to start, and
* abort the active step. The supplied reason is preserved across pre-step
* and active cancellation windows, and `whenIdle()` resolves after
* cancellation reaches quiescence. Idle cancellation is a no-op and does not
* arm a later cancel.
* abort the active step. An effective call first emits `agent/cancel-requested`
* with the resolved reason. That reason is preserved across pre-step and active
* cancellation windows, and `whenIdle()` resolves after cancellation reaches
* quiescence. Idle cancellation is a no-op and does not arm a later cancel.
*/
cancel(reason?: string): void
@@ -176,6 +179,16 @@ declare module 'cordis' {
* @mode emit
*/
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
/**
* Effective broad cancellation was requested, before queued/steering work
* is cleared or the active step is aborted. This observe-only notification
* cannot veto cancellation; listener failures are contained.
* @param agent - the agent whose current work is being cancelled.
* @param reason - resolved cancellation reason, including the default.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/cancel-requested'(this: Scoped<Agent>, agent: Agent, reason: string): void
// ---- session lifecycle (emit) ----
/**
@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'get_goal', 'glob', 'grep', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {
+3 -3
View File
@@ -4,10 +4,10 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
| Package | npm name | Role |
|---|---|---|
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) |
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` |
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with an opt-in persisted-goal stack |
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` |
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + persisted goals + `/goal` command + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
+4 -1
View File
@@ -11,6 +11,8 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it
| Plugin | Why |
|---|---|
| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) |
| `@deepseek-ai/dsh-commands` | the human-command registry used for ACP discovery and direct slash dispatch |
| `@deepseek-ai/dsh-command-goal` | the discoverable direct `/goal` producer; the app enables the spine's persisted-goal stack with it |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) |
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool |
@@ -36,6 +38,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
| `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
| `llmRetry` | owner defaults | bounded transient model-request retry policy routed through `dsh-agent-spine-demo` |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
@@ -56,7 +59,7 @@ All diagnostics go to **stderr** — stdout is the protocol.
## Model Experience
Indirectly, through `dsh-agent-spine-demo` and `dsh-acp`, which compose each ACP agent's prompt, tools, and message history; this app bundle adds no model-bound content itself.
Indirectly, through `dsh-agent-spine-demo` and `dsh-acp`, which compose each ACP agent's prompt, goal tools, and message history. Direct `/goal` input and output remain outside the model, while accepted mutations append domain-owned model-visible snapshots.
#### KV Cache effect
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-acp-demo",
"description": "ACP server app: the agent-spine-demo bundle + JSONL persistence + the ACP bridge (no stdout logger, no hmr, no pre-created agents), with a bin to boot a leaf cordis.yml over JSON-RPC stdio",
"description": "ACP server app: agent spine + human commands + JSONL persistence + ACP bridge (no stdout logger, hmr, or pre-created agents), with a JSON-RPC stdio bin",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -34,6 +34,8 @@
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@deepseek-ai/dsh-acp": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-command-goal": "^0.0.1",
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
@@ -47,6 +49,8 @@
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-acp": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
+11 -3
View File
@@ -1,7 +1,7 @@
/**
* The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}),
* JSONL session persistence, and the {@link @deepseek-ai/dsh-acp} bridge. It
* writes nothing to stdout.
* human-command registry, JSONL session persistence, and the
* {@link @deepseek-ai/dsh-acp} bridge. It writes nothing to stdout.
* It pre-creates no agents and leaves adapters, executors, and optional tools to
* the leaf, which must likewise avoid stdout loggers. Named exports are
* required so Loader retains this plugin's `Config` schema (see
@@ -12,6 +12,8 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import * as acp from '@deepseek-ai/dsh-acp'
import CommandService from '@deepseek-ai/dsh-commands'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
@@ -60,6 +62,8 @@ export interface Config {
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */
goals?: agentCore.GoalConfig | false
/** Bounded transient model-request retry policy forwarded through agent-core. */
llmRetry?: NonNullable<agentCore.Config['llmRetry']>
}
@@ -84,6 +88,7 @@ export const Config: z<Config> = z.object({
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
llmRetry: agentCore.LlmRetryConfigSchema,
})
/* jscpd:ignore-end */
@@ -96,7 +101,10 @@ export const Config: z<Config> = z.object({
* from the provider/model pair. No logger, no `hmr` — stdout stays pure.
*/
export function apply(ctx: Context, config: Config): void {
ctx.plugin(agentCore, agentCore.pickSpineConfig(config))
const goals = config.goals ?? {}
ctx.plugin(CommandService)
if (goals !== false) ctx.plugin(commandGoal)
ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals })
ctx.plugin(UserInteractionService)
ctx.plugin(SessionPersistenceJsonl, {
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
@@ -86,11 +86,30 @@ describe('dsh-acp-demo composition', () => {
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('userInteraction')).toBeDefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()
expect(ctx.get('goals')).toBeDefined()
expect(ctx.get('tools')?.get('get_goal')).toBeDefined()
// No pre-created agents — ACP session/new creates them on demand.
expect(ctx.get('agents')!.list()).toHaveLength(0)
await ctx.fiber.dispose()
})
it('can explicitly omit the persisted-goal stack and its command', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
goals: false,
workspaceContext: false,
})
expect(ctx.get('goals')).toBeUndefined()
const handle = await ctx.agents.create({
sessionId: 'disabled-goals' as import('@deepseek-ai/dsh-session').SessionId,
agentOptions: { provider: 'mock', model: 'mock' },
})
expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined()
await handle.dispose()
await ctx.fiber.dispose()
})
it('defaults the persistence root when omitted', async () => {
// Exercises the `DEFAULT_PERSISTENCE_ROOT` fallback for a direct-apply caller that
// bypasses the schema's `.default(...)`: call `apply` directly (not via
@@ -188,7 +207,17 @@ describe('dsh-acp-demo composition', () => {
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill', 'task_kill', 'task_list', 'task_output'])
expect(assembly.tools.map(tool => tool.name)).toEqual([
'zulu',
'alpha',
'create_goal',
'get_goal',
'skill',
'task_kill',
'task_list',
'task_output',
'update_goal',
])
await ctx.fiber.dispose()
})
+6
View File
@@ -23,6 +23,12 @@
{
"path": "../../ui/acp"
},
{
"path": "../../ui/commands"
},
{
"path": "../../goal/command-goal"
},
{
"path": "../../core/agent"
},
+7 -4
View File
@@ -17,6 +17,9 @@ Read this package for the whole plugin tree and its composition order.
@deepseek-ai/dsh-skill skill provider registry
@deepseek-ai/dsh-skill-local local filesystem skill provider
@deepseek-ai/dsh-agent agent registry + initiator scope + agent/* events
@deepseek-ai/dsh-goal optional persisted same-session goal domain
@deepseek-ai/dsh-tool-goal optional model-facing goal controls
@deepseek-ai/dsh-goal-session optional same-session goal-round driver
@deepseek-ai/dsh-llm-retry bounded transient request retry policy
@deepseek-ai/dsh-tasks generic background-task registry
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
@@ -43,11 +46,11 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl
```ts
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, llmRetry? }
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, llmRetry? }
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
## Why a code bundle, not a shared YAML include
@@ -57,7 +60,7 @@ The bounded retry policy may repeat a transiently failed request in a new number
## Model Experience
Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, `dsh-tools`, and `dsh-llm-retry`, which this bundle mounts without adding model-bound wrapper content.
Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, `dsh-tools`, and `dsh-llm-retry`, plus `dsh-tool-goal` and goal-round prompts when `goals` is enabled. The bundle adds no model-bound wrapper content of its own.
#### KV Cache effect
@@ -65,5 +68,5 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit the bundled skills and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle.
- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit bundled goals, skills, and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle.
- **`dsh-invariants` mounts unconditionally** — this bundle has no toggle, so every composition using it pays the dev-mode relational assertions; Session's always-on validation and freezing are separate.
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-agent-spine-demo",
"description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + bounded retry + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)",
"description": "The default executor-less/UI-less agent spine with bounded retry and optional persisted goals",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -25,6 +25,8 @@
"@cordisjs/plugin-timer": "^1.1.2",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-goal": "^0.0.1",
"@deepseek-ai/dsh-goal-session": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-home": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
@@ -36,6 +38,7 @@
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tool-bash": "^0.0.1",
"@deepseek-ai/dsh-tool-goal": "^0.0.1",
"@deepseek-ai/dsh-tool-skill": "^0.0.1",
"@deepseek-ai/dsh-tool-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
@@ -45,6 +48,8 @@
"@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-session": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-home": "workspace:^",
@@ -57,6 +62,7 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
@@ -1,6 +1,6 @@
/**
* Default executor-less, UI-less agent spine. It bundles the common services,
* background-task registry and controls, concrete loop, local skill and
* background-task registry and controls, optional persisted goals, concrete loop, local skill and
* workspace-context providers, and model-facing bash/skill consumers;
* deployments still choose the LLM adapter, bash executor, and presentation.
* The plugin intentionally exposes named exports only because Loader default
@@ -18,6 +18,9 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools
import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import GoalService, { type Config as GoalDomainConfig } from '@deepseek-ai/dsh-goal'
import * as goalSession from '@deepseek-ai/dsh-goal-session'
import * as toolGoal from '@deepseek-ai/dsh-tool-goal'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as invariants from '@deepseek-ai/dsh-invariants'
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
@@ -42,6 +45,14 @@ export interface SkillConfig {
tool?: toolSkill.Config
}
/** Persisted goal domain, model-tool policy, and same-session driver config. */
export interface GoalConfig {
/** Goal-domain creation defaults. */
domain?: GoalDomainConfig
/** Model-facing goal-tool authority policy. */
tool?: toolGoal.Config
}
/**
* Bundle config: each field forwarded verbatim to the child that owns it —
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
@@ -50,8 +61,10 @@ export interface SkillConfig {
* order), the `tools` object to the tool registry (its presentation `mode`),
* `dshHome` to bash environment and local skill discovery, `skills` to the
* skill registry/local provider/tool consumer, `workspaceContext` to the
* workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool
* plugins this bundle owns. Owner schemas supply defaults for optional input;
* workspace-context loader, `llmRetry` to the bounded request-recovery policy,
* and `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns.
* `goals` opts into and configures the persisted goal
* domain plus its model tool and same-session driver. Owner schemas supply defaults for optional input;
* workspace context instead requires an explicit byte budget or `false` because
* it changes model-visible input. Producer opt-in stays producer-local:
* `toolBash` configures bash only; independently composed producers keep their
@@ -78,6 +91,8 @@ export interface Config {
toolBash?: toolBash.Config
/** Generic background-task controls; set false to keep the task service without model-facing task tools. */
toolTasks?: toolTasks.Config | false
/** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */
goals?: GoalConfig | false
/** Bounded transient model-request retry policy. */
llmRetry?: llmRetry.Config
}
@@ -96,6 +111,12 @@ export const ToolBashConfigSchema: z<toolBash.Config> = toolBash.Config
/** The task-control-tool config schema exported for app packages that forward `toolTasks`. */
export const ToolTasksConfigSchema: z<toolTasks.Config> = toolTasks.Config
/** The persisted-goal config schema exported for app packages that opt in. */
export const GoalConfigSchema: z<GoalConfig> = z.object({
domain: GoalService.Config,
tool: toolGoal.Config,
})
/** The bounded LLM retry schema exported for app packages that forward `llmRetry`. */
export const LlmRetryConfigSchema: z<llmRetry.Config> = llmRetry.Config
@@ -110,8 +131,9 @@ export const Config = z.intersect([
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
toolBash: ToolBashConfigSchema,
toolTasks: z.union([z.const(false), ToolTasksConfigSchema]),
goals: z.union([z.const(false), GoalConfigSchema]),
llmRetry: LlmRetryConfigSchema,
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'llmRetry'>>,
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'goals' | 'llmRetry'>>,
]) as unknown as z<Config>
/**
@@ -130,6 +152,7 @@ export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'a
...config.skills !== undefined ? { skills: config.skills } : {},
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {},
...config.goals !== undefined ? { goals: config.goals } : {},
...config.llmRetry !== undefined ? { llmRetry: config.llmRetry } : {},
}
}
@@ -168,6 +191,11 @@ export function apply(ctx: Context, config: Config): void {
}
ctx.plugin(AgentRegistry)
ctx.plugin(llmRetry, config.llmRetry ?? {})
if (config.goals !== undefined && config.goals !== false) {
ctx.plugin(GoalService, config.goals.domain ?? {})
ctx.plugin(toolGoal, config.goals.tool ?? {})
ctx.plugin(goalSession)
}
ctx.plugin(TaskService)
ctx.plugin(invariants)
ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome }))
@@ -124,6 +124,35 @@ describe('dsh-agent-spine-demo bundle', () => {
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('tasks')).toBeDefined()
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('goals')).toBeUndefined()
await ctx.fiber.dispose()
})
it('opts into the configured persisted-goal domain, tools, and same-session driver', async () => {
const ctx = await mount({
workspaceContext: false,
agents: [{ id: SessionId('configured-goal'), provider: 'mock', model: 'mock' }],
goals: {
domain: { defaultMaxGoalRounds: 17 },
tool: { blockedAfterConsecutiveRounds: 5 },
},
})
const agent = ctx.agents.list()[0]
if (agent === undefined) throw new Error('configured goal test has no live agent')
expect(ctx.goals.create(agent, { objective: 'configured' })).toMatchObject({
objective: 'configured', maxGoalRounds: 17,
})
expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name))
.toEqual(['create_goal', 'get_goal', 'update_goal'])
expect((await ctx.systemPrompt.assemble()).sections.find(section => section.name === 'tool:goal')?.text)
.toContain('at least 5 consecutive goal rounds')
await ctx.fiber.dispose()
})
it('accepts an explicit false goal composition without mounting it', async () => {
const ctx = await mount({ workspaceContext: false, goals: false })
expect(ctx.get('goals')).toBeUndefined()
expect(ctx.tools.get('get_goal')).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -211,6 +240,23 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('uses owner defaults for a schema-bypassing empty goal opt-in', async () => {
const ctx = new Context()
agentCore.apply(ctx, {
workspaceContext: false,
agents: [{ id: SessionId('defaulted-goal'), provider: 'mock', model: 'mock' }],
goals: {},
})
await new Promise(resolve => setTimeout(resolve, 50))
const agent = ctx.agents.list()[0]
if (agent === undefined) throw new Error('default goal test has no live agent')
expect(ctx.goals.create(agent, { objective: 'defaulted' })).toMatchObject({
objective: 'defaulted', maxGoalRounds: 256,
})
expect(ctx.tools.get('get_goal')).toBeDefined()
await ctx.fiber.dispose()
})
it('loads workspace instructions into requests through the bundled spine', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-workspace-context-'))
try {
@@ -41,6 +41,15 @@
{
"path": "../../core/agent"
},
{
"path": "../../goal/goal"
},
{
"path": "../../goal/tool-goal"
},
{
"path": "../../goal/goal-session"
},
{
"path": "../../context/workspace-context"
},
+5 -2
View File
@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-tui-demo
The full-screen terminal app: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). Its `bin` boots a leaf `cordis.yml`.
The full-screen terminal app: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). Its `bin` boots a leaf `cordis.yml`.
Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This package requires a TTY pair and has no line-oriented fallback.
@@ -9,6 +9,8 @@ Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and
| Plugin | Why it is here |
|---|---|
| `@deepseek-ai/dsh-agent-spine-demo` | Shared services, model-facing tools, and one configured `main` agent |
| `@deepseek-ai/dsh-commands` | Human-only discovery and dispatch consumed by the TUI and command plugins |
| `@deepseek-ai/dsh-command-goal` | Direct `/goal` status and mutation over the spine's persisted-goal stack |
| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` |
| `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service |
| `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays |
@@ -30,6 +32,7 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le
| `skills` | owner defaults | Skill registry, local provider, and tool config |
| `toolBash` | owner defaults | Model-facing bash tool config |
| `toolTasks` | owner defaults | Background-task control-tool config, or `false` |
| `goals` | owner defaults | Persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
| `workspaceContext` | required | Workspace-instruction config, or `false` |
| `persistenceRoot` | `./.sessions` | JSONL persistence root |
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
@@ -70,7 +73,7 @@ Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI a
#### What the model sees
Each non-empty editor submission becomes a user message; a submission during a running turn becomes steering. The shared spine contributes the configured persona, workspace instructions, skill catalog, and visible tool schemas. TUI rendering itself is not model-visible.
Each non-empty non-command editor submission becomes a user message; a submission during a running turn becomes steering. Slash-command input and output remain human-only, while accepted `/goal` mutations append domain-owned model-visible state. The shared spine contributes the configured persona, workspace instructions, skill catalog, goal controls, and visible tool schemas. TUI rendering itself is not model-visible.
#### Token effect
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-tui-demo",
"description": "Full-screen terminal app: agent spine + JSONL persistence + pi-tui front door + pre-created main agent",
"description": "Full-screen terminal app: agent spine + persisted goals + human commands + JSONL persistence + pi-tui front door + pre-created main agent",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -35,6 +35,8 @@
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-command-goal": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
@@ -53,6 +55,8 @@
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
+12 -3
View File
@@ -1,8 +1,8 @@
/**
* Full-screen terminal app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo})
* plus JSONL persistence, keyboard-backed user interaction, and one pre-created
* agent whose exact session identity the TUI drives. Swappable adapters,
* executors, optional tools, and HMR stay in the leaf. This Loader plugin
* plus persisted goals, human commands, JSONL persistence, keyboard-backed
* user interaction, and one pre-created agent whose exact session identity the
* TUI drives. Swappable adapters, executors, optional tools, and HMR stay in the leaf. This Loader plugin
* intentionally exposes named exports only; a default export would hide its
* `Config` schema (see docs/postmortem/0001).
* @module @deepseek-ai/dsh-tui-demo
@@ -13,6 +13,8 @@ import { randomUUID } from 'node:crypto'
import z from 'schemastery'
import { SessionId } from '@deepseek-ai/dsh-session'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import CommandService from '@deepseek-ai/dsh-commands'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import SessionPersistenceJsonl, {
@@ -57,6 +59,8 @@ export interface Config {
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */
goals?: agentCore.GoalConfig | false
/** Persisted session id to resume instead of creating a fresh session. */
resumeSessionId?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
@@ -82,6 +86,7 @@ export const Config: z<Config> = z.object({
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
resumeSessionId: z.string(),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
})
@@ -97,6 +102,9 @@ export const Config: z<Config> = z.object({
export function composeTuiApp(ctx: Context, config: Config): void {
const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
const goals = config.goals ?? {}
ctx.plugin(CommandService)
if (goals !== false) ctx.plugin(commandGoal)
ctx.plugin(SessionPersistenceJsonl, {
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
@@ -109,6 +117,7 @@ export function composeTuiApp(ctx: Context, config: Config): void {
})
ctx.plugin(agentCore, {
...agentCore.pickSpineConfig(config),
goals,
agents: [{
id: SessionId('main'),
provider: config.provider,
@@ -41,18 +41,22 @@ describe('dsh-tui-demo app', () => {
})
expect(calls.map(call => call.name)).toEqual([
'CommandService',
'command-goal',
'SessionPersistenceJsonl',
'UserInteractionService',
'ui-tui',
'agent-spine-demo',
'tool-ask-user',
])
expect(calls[0]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
const tuiConfig = calls[2]?.config as { sessionId: string }
expect(calls[0]?.config).toBeUndefined()
expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
const tuiConfig = calls[4]?.config as { sessionId: string }
expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 })
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
const spineConfig = calls[3]?.config as {
const spineConfig = calls[5]?.config as {
readonly agents: Array<Record<string, unknown>>
readonly goals: Record<string, never>
readonly maxParallelToolCalls: number
readonly persona: string
readonly toolOrder: string[]
@@ -63,6 +67,7 @@ describe('dsh-tui-demo app', () => {
persona: 'test persona',
toolOrder: ['zulu', TOOL_ORDER_REST],
tools: { mode: 'code' },
goals: {},
})
expect(spineConfig.agents[0]).toMatchObject({
id: 'main',
@@ -82,9 +87,9 @@ describe('dsh-tui-demo app', () => {
workspaceContext: false,
})
expect(calls[0]?.config).toEqual({ root: './.sessions' })
expect(calls[2]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' })
expect((calls[3]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
expect(calls[2]?.config).toEqual({ root: './.sessions' })
expect(calls[4]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' })
expect((calls[5]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
id: 'main',
resumeSessionId: 'persisted-session',
})
@@ -96,13 +101,16 @@ describe('dsh-tui-demo app', () => {
provider: 'mock',
model: 'mock-model',
resumeSessionId: '',
goals: false,
workspaceContext: false,
})
const tuiConfig = calls[2]?.config as { sessionId: string }
const tuiConfig = calls[3]?.config as { sessionId: string }
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
expect((calls[3]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
expect((calls[4]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
.toMatchObject({ sessionId: tuiConfig.sessionId })
expect(calls.map(call => call.name)).not.toContain('command-goal')
expect(calls[4]?.config).toMatchObject({ goals: false })
})
it('has the namespace-plugin export shape so the Loader keeps its schema', () => {
+6
View File
@@ -26,6 +26,12 @@
{
"path": "../../core/session"
},
{
"path": "../../ui/commands"
},
{
"path": "../../goal/command-goal"
},
{
"path": "../agent-spine-demo"
},
+12
View File
@@ -0,0 +1,12 @@
# goal/ — persisted same-session goals
The goal family owns durable objective state independently of the model-facing tools and continuation policy that consume it.
| Package | Role | ctx key |
|---|---|---|
| `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` |
| `goal-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
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), 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.
+38
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"
}
}
+170
View 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),
})
}
@@ -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')
})
})
+24
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"
}
]
}
+71
View 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.
+42
View File
@@ -0,0 +1,42 @@
{
"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"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-goal": "^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-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
View 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')
}
+53
View 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
View 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>',
}]
}
@@ -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()
})
})
+30
View File
@@ -0,0 +1,30 @@
{
"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"
}
]
}
+54
View File
@@ -0,0 +1,54 @@
# @deepseek-ai/dsh-goal
Event-sourced same-session goal state. The service retains one current completion objective in an agent's existing session while keeping permission to continue as process-local activation. The [goal-domain Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the design rationale; the [goal type catalog](../../../docs/core-data-structures/goal.md) records the literal data shapes.
## Config
```yaml
- id: goal
name: '@deepseek-ai/dsh-goal'
config:
defaultMaxGoalRounds: 256
```
`defaultMaxGoalRounds` must be a positive safe integer. `create()` materializes this deployment default internally before committing a goal; a request-level value overrides it.
## Service contract
`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). Creation default resolution is 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.
Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The `context/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained.
Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. 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.
## Extension points
Policy plugins call the service verbs and react to the scoped `goal/changed` event. A continuation consumer admits rounds as `user/message` events with `GoalMessageSource`; ordinary human turns never increment `roundsStarted`. Consumers use the `Agent` interface and events rather than importing `dsh-agent-loop`.
## Model Experience
### Goal-state mutation
#### What the model sees
Each mutation is one raw user-role context block. A snapshot is rendered as `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. The descriptive XML delimiter follows this repository's existing `<workspace_context>` convention and [Anthropic's published XML-tag prompting guidance](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags); it is public model-experience prior art, not a claim about any provider's proprietary training corpus.
#### Token effect
Every retained mutation adds one full snapshot to derived history until compaction shadows it. Full snapshots make each record independently inspectable but repeat the objective and lifecycle fields.
#### KV Cache effect
Append-only within an epoch: each mutation follows the reusable request prefix and preceding history. Compaction may replace the derived-history suffix and move the reusable boundary.
## Known Limitations and Deferred Work
- **State, not scheduling** — this package does not decide when an armed goal continues, retry abnormal failures, or cancel an active turn; those policies belong to agent-seam consumers.
- **Round-count budget only** — `maxGoalRounds` does not meter tokens, currency, wall time, or provider quotas.
- **No independent evaluator** — the caller that records completion or blocking is authoritative; evaluator-backed certification is deferred to a separate policy layer.
- **One current goal** — parallel objectives and a separate goal database are intentionally absent; history remains available in the session log after replacement or clear.
- **Trusted in-process producers** — a plugin with direct `Session` access can append counterfeit goal metadata. Strict replay detects malformed or inconsistent records and leaves goal access failed at that record until the log is repaired; this is integrity detection, not plugin isolation.
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@deepseek-ai/dsh-goal",
"description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.17.2"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
+377
View File
@@ -0,0 +1,377 @@
/** Pure replay fold and strict decoder for durable goal changes. */
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { renderGoalChange } from './render.ts'
import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts'
import type {
FoldedGoal,
GoalBlockReason,
GoalChangeMeta,
GoalClearChangeMeta,
GoalMessageSource,
GoalOperation,
GoalPhase,
GoalRef,
GoalSnapshot,
GoalSnapshotChangeMeta,
} from './types.ts'
type ContextMessageEvent = Extract<SessionEvent, { type: 'context/message' }>
const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Set([
'create',
'edit',
'pause',
'resume',
'complete',
'block',
])
const PHASES: ReadonlySet<GoalPhase> = new Set(['active', 'paused', 'blocked', 'complete'])
/** Mutable accumulator kept private to the pure fold. */
export interface GoalFoldState {
goal: GoalSnapshot | undefined
roundsStarted: number
createdAt: number | undefined
updatedAt: number | undefined
lastRef: GoalRef | undefined
seenGoalIds: Set<GoalSnapshot['id']>
}
/**
* Build an empty replay accumulator.
* @returns mutable state with no current goal or prior ref.
*/
export function emptyGoalFoldState(): GoalFoldState {
return {
goal: undefined,
roundsStarted: 0,
createdAt: undefined,
updatedAt: undefined,
lastRef: undefined,
seenGoalIds: new Set(),
}
}
/** Whether a value is a JSON record rather than an array. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Require one positive safe integer. */
function positiveInteger(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
throw new Error(`goal change ${field} must be a positive safe integer`)
}
return value
}
/** Require one non-negative safe integer. */
function nonNegativeInteger(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
throw new Error(`goal change ${field} must be a non-negative safe integer`)
}
return value
}
/** Decode one canonical blocker explanation. */
function decodeBlockReason(value: unknown): GoalBlockReason {
if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'code,message') {
throw new Error('goal change goal.blockedReason has an invalid shape')
}
if (typeof value['code'] !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value['code'])) {
throw new Error('goal change goal.blockedReason.code must be lower-kebab-case')
}
if (typeof value['message'] !== 'string' || value['message'].trim().length === 0
|| value['message'] !== value['message'].trim()) {
throw new Error('goal change goal.blockedReason.message must be non-empty and normalized')
}
return { code: value['code'], message: value['message'] }
}
/** Decode and validate one snapshot. */
function decodeSnapshot(value: unknown): GoalSnapshot {
if (!isRecord(value)) throw new Error('goal change goal must be a record')
if (typeof value['id'] !== 'string' || value['id'].length === 0) {
throw new Error('goal change goal.id must be a non-empty string')
}
if (typeof value['objective'] !== 'string' || value['objective'].trim().length === 0
|| value['objective'] !== value['objective'].trim()) {
throw new Error('goal change goal.objective must be non-empty and normalized')
}
if (typeof value['phase'] !== 'string' || !PHASES.has(value['phase'] as GoalPhase)) {
throw new Error('goal change goal.phase is invalid')
}
const phase = value['phase'] as GoalPhase
const expectedKeys = phase === 'blocked'
? 'blockedReason,id,maxGoalRounds,objective,phase,revision'
: 'id,maxGoalRounds,objective,phase,revision'
if (Object.keys(value).sort().join(',') !== expectedKeys) {
throw new Error('goal change goal has an invalid shape')
}
return {
id: GoalId(value['id']),
revision: positiveInteger(value['revision'], 'goal.revision'),
objective: value['objective'],
phase,
maxGoalRounds: positiveInteger(value['maxGoalRounds'], 'goal.maxGoalRounds'),
...phase === 'blocked' ? { blockedReason: decodeBlockReason(value['blockedReason']) } : {},
}
}
/** Decode and validate one ref. */
function decodeRef(value: unknown): GoalRef {
if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'id,revision') {
throw new Error('goal clear tombstone has an invalid shape')
}
if (typeof value['id'] !== 'string' || value['id'].length === 0) {
throw new Error('goal clear tombstone id must be a non-empty string')
}
return { id: GoalId(value['id']), revision: positiveInteger(value['revision'], 'cleared.revision') }
}
/**
* Decode metadata that declares itself as a goal change. Unrelated metadata
* returns `undefined`; malformed goal metadata fails replay loudly.
* @param value - context-message metadata.
* @returns validated goal change or `undefined` for another metadata kind.
*/
export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined {
if (!isRecord(value) || value['kind'] !== 'goal/change') return undefined
if (value['version'] !== GOAL_CHANGE_VERSION) {
throw new Error(`unsupported goal change version ${String(value['version'])}`)
}
if (value['operation'] === 'clear') {
const allowed = ['cleared', 'clearedAt', 'kind', 'operation', 'version']
if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) {
throw new Error('goal clear change has an invalid shape')
}
return {
kind: 'goal/change',
version: GOAL_CHANGE_VERSION,
operation: 'clear',
cleared: decodeRef(value['cleared']),
clearedAt: nonNegativeInteger(value['clearedAt'], 'clearedAt'),
} satisfies GoalClearChangeMeta
}
if (typeof value['operation'] !== 'string'
|| !SNAPSHOT_OPERATIONS.has(value['operation'] as Exclude<GoalOperation, 'clear'>)) {
throw new Error('goal change operation is invalid')
}
const allowed = ['createdAt', 'goal', 'kind', 'operation', 'roundsStarted', 'updatedAt', 'version']
if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) {
throw new Error('goal snapshot change has an invalid shape')
}
const createdAt = nonNegativeInteger(value['createdAt'], 'createdAt')
const updatedAt = nonNegativeInteger(value['updatedAt'], 'updatedAt')
if (updatedAt < createdAt) throw new Error('goal change updatedAt cannot precede createdAt')
return {
kind: 'goal/change',
version: GOAL_CHANGE_VERSION,
operation: value['operation'] as Exclude<GoalOperation, 'clear'>,
goal: decodeSnapshot(value['goal']),
roundsStarted: nonNegativeInteger(value['roundsStarted'], 'roundsStarted'),
createdAt,
updatedAt,
} satisfies GoalSnapshotChangeMeta
}
/** Narrow model attribution to a valid goal source. */
function goalSource(source: MessageSource): GoalMessageSource | undefined {
if (source.kind !== 'goal') return undefined
if (typeof source.goalId !== 'string' || source.goalId.length === 0
|| !Number.isSafeInteger(source.revision) || source.revision < 1
|| !Number.isSafeInteger(source.round) || source.round < 0) {
throw new Error('goal message source is invalid')
}
return source
}
/** Require two snapshots to retain fields that only `edit` may replace. */
function requireSameDefinition(current: GoalSnapshot, next: GoalSnapshot, operation: GoalOperation): void {
if (next.objective !== current.objective || next.maxGoalRounds !== current.maxGoalRounds) {
throw new Error(`goal ${operation} cannot change objective or maxGoalRounds`)
}
}
/** Require one exact next revision of the current goal. */
function requireNextRevision(current: GoalSnapshot, next: GoalRef, operation: GoalOperation): void {
if (next.id !== current.id || next.revision !== current.revision + 1) {
throw new Error(`goal ${operation} must advance the current goal by one revision`)
}
}
/** Validate one non-create snapshot operation against the preceding projection. */
function validateSnapshotTransition(
state: GoalFoldState,
change: GoalSnapshotChangeMeta,
current: GoalSnapshot,
): void {
const next = change.goal
requireNextRevision(current, next, change.operation)
/* v8 ignore next -- a current goal established by this fold always has an updatedAt */
if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt')
if (change.createdAt !== state.createdAt
|| change.updatedAt < state.updatedAt
|| change.roundsStarted !== state.roundsStarted) {
throw new Error(`goal ${change.operation} does not preserve the current counters and timestamps`)
}
switch (change.operation) {
case 'edit':
if (next.phase !== current.phase
|| JSON.stringify(next.blockedReason) !== JSON.stringify(current.blockedReason)) {
throw new Error('goal edit cannot change phase or blocked reason')
}
break
case 'pause':
requireSameDefinition(current, next, change.operation)
if (current.phase !== 'active' || next.phase !== 'paused') throw new Error('goal pause has an invalid phase transition')
break
case 'resume': {
requireSameDefinition(current, next, change.operation)
const resumable: ReadonlySet<GoalPhase> = new Set([
'active',
'paused',
'blocked',
])
if (!resumable.has(current.phase) || next.phase !== 'active' || state.roundsStarted >= next.maxGoalRounds) {
throw new Error('goal resume has an invalid phase transition or exhausted round budget')
}
break
}
case 'complete':
requireSameDefinition(current, next, change.operation)
if (current.phase === 'complete' || next.phase !== 'complete') throw new Error('goal complete has an invalid phase transition')
break
case 'block':
requireSameDefinition(current, next, change.operation)
if (current.phase !== 'active' || next.phase !== 'blocked') throw new Error('goal block has an invalid phase transition')
break
/* v8 ignore start -- the caller excludes create and GoalOperation is closed; these arms retain fail-loud exhaustiveness */
case 'create':
throw new Error('goal create cannot be validated as a current-goal transition')
default:
change.operation satisfies never
throw new Error('unknown goal snapshot operation')
/* v8 ignore stop */
}
}
/**
* Return the revision identity carried by a snapshot or tombstone.
* @param change - decoded goal mutation.
* @returns stable identity used to reconcile a deferred change with its log event.
*/
export function goalChangeRef(change: GoalChangeMeta): GoalRef {
return change.operation === 'clear' ? change.cleared : change.goal
}
/**
* Validate and apply one decoded change to a mutable accumulator.
* @param state - preceding durable goal projection.
* @param change - decoded full snapshot or clear tombstone.
*/
export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): void {
const ref = goalChangeRef(change)
if (change.operation === 'clear') {
const current = state.goal
if (current === undefined) throw new Error('goal clear requires a current goal')
requireNextRevision(current, change.cleared, change.operation)
/* v8 ignore next -- a current goal established by this fold always has an updatedAt */
if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt')
if (change.clearedAt < state.updatedAt) {
throw new Error('goal clear timestamp cannot precede the current goal update')
}
state.goal = undefined
state.roundsStarted = 0
state.createdAt = undefined
state.updatedAt = undefined
state.lastRef = ref
return
}
if (change.operation === 'create') {
if (change.goal.revision !== 1 || change.goal.phase !== 'active' || change.roundsStarted !== 0
|| (state.goal !== undefined && state.goal.phase !== 'complete')
|| state.seenGoalIds.has(change.goal.id)) {
throw new Error('goal create requires a fresh active revision-one goal with zero rounds')
}
state.seenGoalIds.add(change.goal.id)
} else {
const current = state.goal
if (current === undefined) throw new Error(`goal ${change.operation} requires a current goal`)
validateSnapshotTransition(state, change, current)
}
state.goal = change.goal
state.roundsStarted = change.roundsStarted
state.createdAt = change.createdAt
state.updatedAt = change.updatedAt
state.lastRef = ref
}
/**
* Decode and verify one model-visible goal context event without folding it.
* @param event - context event whose metadata and rendered content must agree.
* @returns validated change or `undefined` for an unrelated context event.
*/
export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | undefined {
const change = decodeGoalChange(event.data.meta)
const source = goalSource(event.data.source)
if (change === undefined) {
if (source !== undefined) throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`)
return undefined
}
const ref = goalChangeRef(change)
if (source === undefined || source.goalId !== ref.id || source.revision !== ref.revision || source.round !== 0) {
throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`)
}
if (JSON.stringify(event.data.content) !== JSON.stringify(renderGoalChange(change))) {
throw new Error(`goal change at session event ${event.seq} has mismatched model-visible content`)
}
return change
}
/**
* Apply one session event and return its goal change, when present.
* @param state - mutable fold accumulator.
* @param event - next event in sequence order.
* @returns decoded change for pending-overlay reconciliation.
*/
export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalChangeMeta | undefined {
if (event.type === 'context/message') {
const change = decodeGoalEvent(event)
if (change === undefined) return undefined
applyGoalChange(state, change)
return change
}
if (event.type === 'user/message') {
const source = goalSource(event.data.source)
if (source !== undefined) {
const current = state.goal
if (current === undefined || current.phase !== 'active' || source.goalId !== current.id
|| source.revision !== current.revision || source.round !== state.roundsStarted + 1
|| source.round > current.maxGoalRounds) {
throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`)
}
state.roundsStarted = source.round
}
}
return undefined
}
/**
* Fold current goal state from a contiguous session event log.
* @param events - session events in sequence order.
* @returns a fresh durable projection; activation is deliberately absent.
*/
export function foldGoal(events: readonly SessionEvent[]): FoldedGoal {
const state = emptyGoalFoldState()
for (const event of events) applyGoalEvent(state, event)
return {
...state.goal === undefined ? {} : { goal: { ...state.goal } },
roundsStarted: state.roundsStarted,
...state.createdAt === undefined ? {} : { createdAt: state.createdAt },
...state.updatedAt === undefined ? {} : { updatedAt: state.updatedAt },
...state.lastRef === undefined ? {} : { lastRef: { ...state.lastRef } },
}
}
+544
View File
@@ -0,0 +1,544 @@
/**
* Same-session goal domain: event-sourced state, compare-and-set mutations,
* and process-local continuation activation.
* @module @deepseek-ai/dsh-goal
*/
import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue, Session } from '@deepseek-ai/dsh-session'
import {
applyGoalChange,
applyGoalEvent,
decodeGoalEvent,
emptyGoalFoldState,
goalChangeRef,
} from './fold.ts'
import type { GoalFoldState } from './fold.ts'
import { renderGoalChange } from './render.ts'
import {
GOAL_CHANGE_VERSION,
GoalError,
GoalId,
} from './runtime.ts'
import type {
CreateGoalRequest,
EditGoalRequest,
GoalActivation,
GoalBlockReason,
GoalChangeMeta,
GoalChanged,
GoalClearChangeMeta,
GoalOperation,
GoalPhase,
GoalRef,
GoalSnapshot,
GoalSnapshotChangeMeta,
GoalView,
} from './types.ts'
export * from './types.ts'
export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts'
export { decodeGoalChange, foldGoal, goalChangeRef } from './fold.ts'
export { renderGoalChange } from './render.ts'
declare module 'cordis' {
interface Context {
goals: GoalService
}
}
/** Deployment defaults for goal creation. */
export interface Config {
/** Total rounds used when a create request omits its own cap. */
defaultMaxGoalRounds?: number
}
/** Resolved defaults. */
export interface ResolvedConfig {
/** Validated positive safe-integer default round cap. */
defaultMaxGoalRounds: number
}
/** One accepted mutation waiting to enter or be observed in the session log. */
interface PendingGoalChange {
readonly change: GoalChangeMeta
readonly activation: GoalActivation
applied: boolean
}
/** Process-local cache plus mutations waiting in the active tool-batch FIFO. */
interface GoalCache {
readonly state: GoalFoldState
activation: GoalActivation
observedSeq: number
readonly pending: PendingGoalChange[]
}
/** Validated create input with every deployment default materialized. */
interface ResolvedCreateGoal {
readonly objective: string
readonly maxGoalRounds: number
}
/** Validate a caller-visible positive safe-integer round cap. */
function resolveMaxGoalRounds(value: number): number {
if (!Number.isSafeInteger(value) || value < 1) {
throw new GoalError('maxGoalRounds must be a positive safe integer', 'GOAL_INVALID_MAX_ROUNDS')
}
return value
}
/** Validate and normalize an objective at the domain boundary. */
function resolveObjective(value: string): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new GoalError('goal objective must be a non-empty string', 'GOAL_INVALID_OBJECTIVE')
}
return value.trim()
}
/** Materialize deployment defaults and validate one create request. */
function resolveCreateGoal(request: CreateGoalRequest, defaultMaxGoalRounds: number): ResolvedCreateGoal {
return {
objective: resolveObjective(request.objective),
maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? defaultMaxGoalRounds),
}
}
/** Validate and detach one policy-owned blocker explanation. */
function resolveBlockReason(reason: unknown): GoalBlockReason {
const record = typeof reason === 'object' && reason !== null && !Array.isArray(reason)
? reason as Record<string, unknown>
: undefined
const code = record?.['code']
const message = record?.['message']
if (typeof code !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(code)
|| typeof message !== 'string' || message.trim().length === 0) {
throw new GoalError(
'goal block reason requires a lower-kebab-case code and a non-empty message',
'GOAL_INVALID_BLOCK_REASON',
)
}
return { code, message: message.trim() }
}
/** Compare the complete canonical payloads used for deferred reconciliation. */
function sameChange(left: GoalChangeMeta, right: GoalChangeMeta): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
/** Goal service (`ctx.goals`) backed exclusively by the owning session log. */
export class GoalService extends Service {
static inject = ['agents']
static Config: z<Config> = z.object({
defaultMaxGoalRounds: z.number().default(256),
})
private readonly resolved: ResolvedConfig
private readonly caches = new WeakMap<Session, GoalCache>()
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'goals')
this.resolved = {
defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256),
}
ctx.on('agent/session-start', (agent) => {
this.cache(agent.session).activation = 'disarmed'
})
}
/**
* Read the current goal for one exact live agent.
* @param agent - owning live agent.
* @returns a fresh view or `undefined` when no goal is current.
* @throws {@link GoalError} when the agent is not the registry's live instance.
*/
get(agent: Agent): GoalView | undefined {
this.assertLive(agent)
const cache = this.cache(agent.session)
this.sync(agent.session, cache)
return this.view(cache)
}
/**
* 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.
* @param agent - owning live agent.
* @param request - objective and optional round cap.
* @returns the created live view.
*/
create(agent: Agent, request: CreateGoalRequest): GoalView {
const spec = resolveCreateGoal(request, this.resolved.defaultMaxGoalRounds)
const cache = this.prepareMutation(agent)
const current = cache.state.goal
if (current !== undefined && current.phase !== 'complete') {
throw new GoalError(`goal "${current.id}" already exists with phase "${current.phase}"`, 'GOAL_ALREADY_EXISTS')
}
const now = Date.now()
const goal: GoalSnapshot = {
id: GoalId(`goal-${randomUUID()}`),
revision: 1,
objective: spec.objective,
phase: 'active',
maxGoalRounds: spec.maxGoalRounds,
}
return this.commitSnapshot(agent, cache, 'create', goal, 0, now, now, 'armed')
}
/**
* Edit objective and/or round cap without changing phase.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @param request - at least one replacement field.
* @returns the edited view.
*/
edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
if (request.objective === undefined && request.maxGoalRounds === undefined) {
throw new GoalError('goal edit requires objective and/or maxGoalRounds', 'GOAL_INVALID_EDIT')
}
const goal: GoalSnapshot = {
...current,
revision: current.revision + 1,
...request.objective === undefined ? {} : { objective: resolveObjective(request.objective) },
...request.maxGoalRounds === undefined ? {} : { maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds) },
}
return this.commitCurrent(agent, cache, 'edit', goal, cache.activation)
}
/**
* Pause an active goal and disarm automatic continuation.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the paused view.
*/
pause(agent: Agent, ref: GoalRef): GoalView {
return this.transition(agent, ref, 'pause', ['active'], 'paused', 'disarmed')
}
/**
* Resume and arm a stopped goal, or rearm an active goal after a
* session-start edge, while its round budget still has capacity.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the active view.
*/
resume(agent: Agent, ref: GoalRef): GoalView {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
const resumable: readonly GoalPhase[] = ['active', 'paused', 'blocked']
if (!resumable.includes(current.phase)) {
throw this.transitionError(current, 'resume', resumable)
}
if (current.phase === 'active' && cache.activation === 'armed') {
throw new GoalError(`goal "${current.id}" is already active and armed`, 'GOAL_INVALID_TRANSITION')
}
if (cache.state.roundsStarted >= current.maxGoalRounds) {
throw new GoalError(
`goal "${current.id}" exhausted ${current.maxGoalRounds} goal rounds; increase maxGoalRounds before resuming`,
'GOAL_INVALID_TRANSITION',
)
}
return this.commitCurrent(agent, cache, 'resume', this.withPhase(current, 'active'), 'armed')
}
/**
* Mark a current non-complete goal complete and disarm it.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the completed view.
*/
complete(agent: Agent, ref: GoalRef): GoalView {
return this.transition(
agent,
ref,
'complete',
['active', 'paused', 'blocked'],
'complete',
'disarmed',
)
}
/**
* Mark an active goal blocked and disarm it.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @param reason - policy-owned stable code and human-readable explanation.
* @returns the blocked view with its durable reason.
*/
block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
if (current.phase !== 'active') {
throw this.transitionError(current, 'block', ['active'])
}
return this.commitCurrent(
agent,
cache,
'block',
{ ...this.withPhase(current, 'blocked'), blockedReason: resolveBlockReason(reason) },
'disarmed',
)
}
/**
* Clear the current goal while retaining a durable tombstone and history.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the tombstone ref whose revision is one past the cleared snapshot.
*/
clear(agent: Agent, ref: GoalRef): GoalRef {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
const tombstone: GoalRef = { id: current.id, revision: current.revision + 1 }
const change: GoalClearChangeMeta = {
kind: 'goal/change',
version: GOAL_CHANGE_VERSION,
operation: 'clear',
cleared: tombstone,
clearedAt: this.nextMutationTime(cache),
}
this.commit(agent, cache, change, 'disarmed')
return { ...tombstone }
}
/** Resolve and validate the cache used by a mutation. */
private prepareMutation(agent: Agent): GoalCache {
this.assertLive(agent)
const cache = this.cache(agent.session)
this.sync(agent.session, cache)
return cache
}
/** Reject stale or missing current-state refs. */
private expectCurrent(cache: GoalCache, ref: GoalRef): GoalSnapshot {
const current = cache.state.goal
if (current === undefined) throw new GoalError('no current goal', 'GOAL_NOT_FOUND')
if (ref.id !== current.id || ref.revision !== current.revision) {
throw new GoalError(
`stale goal ref "${ref.id}" revision ${ref.revision}; current is "${current.id}" revision ${current.revision}`,
'GOAL_STALE_REVISION',
)
}
return current
}
/** Enforce exact live-agent identity rather than trusting a matching id. */
private assertLive(agent: Agent): void {
if (this.ctx.agents.get(agent.id) !== agent || agent.status === 'disposed') {
throw new GoalError(`agent "${agent.id}" is not live in this registry`, 'GOAL_AGENT_NOT_LIVE')
}
}
/** Return the per-session cache, folding a seed once with activation disarmed. */
private cache(session: Session): GoalCache {
let cache = this.caches.get(session)
if (cache !== undefined) return cache
const state = emptyGoalFoldState()
for (const event of session.events) applyGoalEvent(state, event)
cache = {
state,
activation: 'disarmed',
observedSeq: session.seq,
pending: [],
}
this.caches.set(session, cache)
return cache
}
/** Incrementally observe durable events without losing deferred mutations. */
private sync(session: Session, cache: GoalCache): void {
for (const event of session.events.slice(cache.observedSeq)) {
if (event.type === 'context/message') {
const change = decodeGoalEvent(event)
if (change !== undefined) {
const pending = cache.pending[0]
if (pending !== undefined && sameChange(pending.change, change)) {
if (!pending.applied) {
applyGoalChange(cache.state, change)
cache.activation = pending.activation
pending.applied = true
}
cache.pending.shift()
cache.observedSeq += 1
continue
}
}
}
applyGoalEvent(cache.state, event)
cache.observedSeq += 1
}
}
/** Build a new revision with one replacement phase. */
private withPhase(current: GoalSnapshot, phase: GoalPhase): GoalSnapshot {
return {
id: current.id,
revision: current.revision + 1,
objective: current.objective,
phase,
maxGoalRounds: current.maxGoalRounds,
}
}
/** Shared validated phase transition. */
private transition(
agent: Agent,
ref: GoalRef,
operation: Exclude<GoalOperation, 'create' | 'edit' | 'clear'>,
allowed: readonly GoalPhase[],
phase: GoalPhase,
activation: GoalActivation,
): GoalView {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
if (!allowed.includes(current.phase)) throw this.transitionError(current, operation, allowed)
return this.commitCurrent(agent, cache, operation, this.withPhase(current, phase), activation)
}
/** Render a stable invalid-transition error. */
private transitionError(current: GoalSnapshot, operation: GoalOperation, allowed: readonly GoalPhase[]): GoalError {
return new GoalError(
`cannot ${operation} goal "${current.id}" from phase "${current.phase}"; expected ${allowed.join(' or ')}`,
'GOAL_INVALID_TRANSITION',
)
}
/** Commit a mutation that retains the current goal's derived counters/times. */
private commitCurrent(
agent: Agent,
cache: GoalCache,
operation: Exclude<GoalOperation, 'create' | 'clear'>,
goal: GoalSnapshot,
activation: GoalActivation,
): GoalView {
const createdAt = cache.state.createdAt
/* v8 ignore next -- strict replay and every snapshot commit set createdAt whenever a current goal exists */
if (createdAt === undefined) throw new Error('current goal cache lacks createdAt')
return this.commitSnapshot(
agent,
cache,
operation,
goal,
cache.state.roundsStarted,
createdAt,
this.nextMutationTime(cache),
activation,
)
}
/** Clamp a current goal's next timestamp across backward wall-clock movement. */
private nextMutationTime(cache: GoalCache): number {
const updatedAt = cache.state.updatedAt
/* v8 ignore next -- strict replay and every snapshot commit set updatedAt whenever a current goal exists */
if (updatedAt === undefined) throw new Error('current goal cache lacks updatedAt')
return Math.max(Date.now(), updatedAt)
}
/** Build and commit one full-snapshot mutation. */
private commitSnapshot(
agent: Agent,
cache: GoalCache,
operation: Exclude<GoalOperation, 'clear'>,
goal: GoalSnapshot,
roundsStarted: number,
createdAt: number,
updatedAt: number,
activation: GoalActivation,
): GoalView {
const change: GoalSnapshotChangeMeta = {
kind: 'goal/change',
version: GOAL_CHANGE_VERSION,
operation,
goal,
roundsStarted,
createdAt,
updatedAt,
}
this.commit(agent, cache, change, activation)
const view = this.view(cache)
/* v8 ignore next -- applyGoalChange installs the snapshot immediately before this read */
if (view === undefined) throw new Error('snapshot commit cleared the goal unexpectedly')
return view
}
/** Accept one mutation into the agent log/FIFO, cache, and live event stream. */
private commit(agent: Agent, cache: GoalCache, change: GoalChangeMeta, activation: GoalActivation): void {
const ref = goalChangeRef(change)
// snapshotJsonValue preserves its input type for callers that already have
// a JsonValue; this interface is structurally JSON but intentionally has no
// index signature, so narrow the validated output at this boundary.
const meta = snapshotJsonValue(change) as JsonValue | undefined
/* v8 ignore next -- validated goal changes contain only finite JSON primitives and records */
if (meta === undefined) throw new Error('goal change is not losslessly JSON-serializable')
const pending: PendingGoalChange = { change, activation, applied: false }
cache.pending.push(pending)
try {
agent.inject(renderGoalChange(change), {
source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0 },
meta,
})
} catch (error: unknown) {
const index = cache.pending.indexOf(pending)
/* v8 ignore next -- a committed goal append cannot reject after its contained observers run */
if (index < 0) throw new Error('goal injection failed after its pending mutation was reconciled', { cause: error })
cache.pending.splice(index, 1)
throw error
}
if (!pending.applied) {
applyGoalChange(cache.state, change)
cache.activation = activation
pending.applied = true
}
this.sync(agent.session, cache)
const goal = this.view(cache)
const notification: GoalChanged = {
operation: change.operation,
ref: { ...ref },
...goal === undefined ? {} : { goal },
}
agentEvents(this.ctx, agent).emit('goal/changed', notification)
}
/** Build a detached current view. */
private view(cache: GoalCache): GoalView | undefined {
const goal = cache.state.goal
const createdAt = cache.state.createdAt
const updatedAt = cache.state.updatedAt
if (goal === undefined) return undefined
/* v8 ignore next 3 -- strict replay and snapshot commits establish both timestamps with every current goal */
if (createdAt === undefined || updatedAt === undefined) {
throw new Error(`goal "${goal.id}" cache lacks timestamps`)
}
return {
...goal,
roundsStarted: cache.state.roundsStarted,
createdAt,
updatedAt,
activation: cache.activation,
}
}
}
export default GoalService
+21
View File
@@ -0,0 +1,21 @@
/** Model-visible rendering for durable goal mutations. */
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { GoalChangeMeta } from './types.ts'
/**
* Render a complete goal snapshot or clear tombstone without hidden prose.
* @param change - durable goal change metadata.
* @returns the single context block logged and projected verbatim for model reconstruction.
*/
export function renderGoalChange(change: GoalChangeMeta): ContentBlock[] {
const payload = change.operation === 'clear'
? { cleared: change.cleared, clearedAt: change.clearedAt }
: {
goal: change.goal,
roundsStarted: change.roundsStarted,
createdAt: change.createdAt,
updatedAt: change.updatedAt,
}
return [{ type: 'text', text: `<goal_state>${JSON.stringify(payload)}</goal_state>` }]
}
+29
View File
@@ -0,0 +1,29 @@
/** Runtime constructors and protocol constants for the goal domain. */
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts'
/** Version of the goal change metadata embedded in `context/message`. */
export const GOAL_CHANGE_VERSION = 1
/**
* Brand a string as a goal id.
* @param id - raw goal identifier.
* @returns the same string with the compile-time brand.
*/
export function GoalId(id: string): GoalIdType {
return id as GoalIdType
}
/** Error returned by the goal domain boundary. */
export class GoalError extends HarnessError {
/**
* @param message - human-readable rejection reason.
* @param code - stable machine-routable classification.
*/
// Keep the constructor to narrow HarnessError's string code at this boundary.
// eslint-disable-next-line @typescript-eslint/no-useless-constructor -- type-only narrowing
constructor(message: string, code: GoalErrorCode) {
super(message, code)
}
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Durable and live vocabulary for one same-session goal.
* @module @deepseek-ai/dsh-goal/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Agent } from '@deepseek-ai/dsh-agent'
/** Identifies one goal across its durable revisions. */
export type GoalId = Branded<'GoalId'>
/** Compare-and-set identity for one exact goal revision. */
export interface GoalRef {
/** Stable goal identity. */
readonly id: GoalId
/** Positive revision; every durable mutation increments it. */
readonly revision: number
}
/** Durable continuation phase. Activation is process-local and separate. */
export type GoalPhase =
| 'active'
| 'paused'
| 'blocked'
| 'complete'
/** Machine-routable and human-readable explanation for a blocked goal. */
export interface GoalBlockReason {
/** Stable lower-kebab-case classification chosen by the blocking policy. */
readonly code: string
/** Non-empty explanation shown to humans and models. */
readonly message: string
}
/** Full durable state written by every non-clear goal mutation. */
export interface GoalSnapshot extends GoalRef {
/** Human-requested completion objective. */
readonly objective: string
/** Durable lifecycle phase. */
readonly phase: GoalPhase
/** Present exactly while `phase` is `blocked`. */
readonly blockedReason?: GoalBlockReason
/** Total admitted goal-round cap. */
readonly maxGoalRounds: number
}
/** Whether this live process may automatically continue an active goal. */
export type GoalActivation = 'armed' | 'disarmed'
/** Current goal projection, including values derived from the session log. */
export interface GoalView extends GoalSnapshot {
/** Highest admitted round number for this goal. */
readonly roundsStarted: number
/** Epoch milliseconds of the create mutation. */
readonly createdAt: number
/** Epoch milliseconds of the latest mutation. */
readonly updatedAt: number
/** Process-local continuation eligibility; never persisted. */
readonly activation: GoalActivation
}
/** Goal state-changing verbs recorded in the durable change metadata. */
export type GoalOperation =
| 'create'
| 'edit'
| 'pause'
| 'resume'
| 'complete'
| 'block'
| 'clear'
/** Full-snapshot goal mutation retained in a model-visible context event. */
export interface GoalSnapshotChangeMeta {
readonly kind: 'goal/change'
readonly version: 1
readonly operation: Exclude<GoalOperation, 'clear'>
readonly goal: GoalSnapshot
readonly roundsStarted: number
readonly createdAt: number
readonly updatedAt: number
}
/** Tombstone retained when the current goal is cleared. */
export interface GoalClearChangeMeta {
readonly kind: 'goal/change'
readonly version: 1
readonly operation: 'clear'
readonly cleared: GoalRef
readonly clearedAt: number
}
/** Durable metadata union carried by a goal-owned `context/message`. */
export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
/** Message attribution for durable goal state and continuation rounds. */
export interface GoalMessageSource {
readonly kind: 'goal'
readonly goalId: GoalId
readonly revision: number
/** Zero for state changes; positive for admitted continuation rounds. */
readonly round: number
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
goal: GoalMessageSource
}
}
/** Pure replay fold of durable goal facts. */
export interface FoldedGoal {
/** Current goal, absent after a clear or before the first create. */
readonly goal?: GoalSnapshot
/** Highest admitted round for the current goal. */
readonly roundsStarted: number
/** Current goal creation time, absent without a current goal. */
readonly createdAt?: number
/** Current goal mutation time, absent without a current goal. */
readonly updatedAt?: number
/** Latest mutation ref, including a clear tombstone. */
readonly lastRef?: GoalRef
}
/** Input whose omitted round cap is resolved by the service configuration. */
export interface CreateGoalRequest {
readonly objective: string
readonly maxGoalRounds?: number
}
/** Fields changed by an edit; at least one must be present. */
export interface EditGoalRequest {
readonly objective?: string
readonly maxGoalRounds?: number
}
/** Live notification after one goal mutation has been accepted for logging. */
export interface GoalChanged {
readonly operation: GoalOperation
readonly ref: GoalRef
/** Absent for a clear tombstone. */
readonly goal?: GoalView
}
/** Stable error codes for rejected goal reads and mutations. */
export type GoalErrorCode =
| 'GOAL_AGENT_NOT_LIVE'
| 'GOAL_NOT_FOUND'
| 'GOAL_ALREADY_EXISTS'
| 'GOAL_STALE_REVISION'
| 'GOAL_INVALID_OBJECTIVE'
| 'GOAL_INVALID_MAX_ROUNDS'
| 'GOAL_INVALID_BLOCK_REASON'
| 'GOAL_INVALID_EDIT'
| 'GOAL_INVALID_TRANSITION'
declare module 'cordis' {
interface Events {
/**
* Goal mutation accepted by one live agent. The matching context event is
* already appended or queued in that agent's active tool-batch FIFO.
* Listener failures are contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @param agent - agent whose session owns the goal.
* @param change - fresh current projection or clear tombstone.
* @mode emit
*/
'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, agent: Agent, change: GoalChanged): void
}
}
+75
View File
@@ -0,0 +1,75 @@
import { readFile, readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { decodeGoalChange, renderGoalChange } from '@deepseek-ai/dsh-goal'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL(
'../../../../examples/headless-agent/tests/fixtures/goal-domain/cordis.yml',
import.meta.url,
))
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
async function jsonlFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true })
const paths = await Promise.all(entries.map(async (entry) => {
const path = join(dir, entry.name)
if (entry.isDirectory()) return jsonlFiles(path)
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
}))
return paths.flat()
}
describe('goal domain through a real cordis.yml and headless process', () => {
it('persists the Loader-mounted snapshot without starting a goal round', async () => {
let events: SessionEvent[] = []
const { stdout, stderr } = await runLoaderSmoke({
label: 'goal-domain',
tempDirPrefix: 'goal-domain-e2e-',
binScript,
configPath,
binArgs: ['--config', configPath, '--output-format', 'json', 'prove the persisted goal domain'],
tsconfigPath: repoTsconfig,
inspect: async (cwd) => {
const logs = await jsonlFiles(join(cwd, '.sessions'))
expect(logs).toHaveLength(1)
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
},
})
expect(stderr).toBe('')
const result = JSON.parse(stdout) as Record<string, unknown>
expect(result).toMatchObject({
type: 'result',
success: true,
})
expect(result['result']).toBeTypeOf('string')
expect(result['result']).toContain('CLI tool round trip complete')
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1)
const contexts = events.filter(event => event.type === 'context/message'
&& event.data.source.kind === 'goal')
expect(contexts).toHaveLength(1)
const context = contexts[0]
if (context?.type !== 'context/message') throw new Error('expected goal context event')
const change = decodeGoalChange(context.data.meta)
if (change === undefined) throw new Error('expected durable goal change')
expect(change).toMatchObject({
operation: 'create',
roundsStarted: 0,
goal: {
revision: 1,
objective: 'Prove the composed goal survives in the session log',
phase: 'active',
maxGoalRounds: 7,
},
})
expect(context.data.content).toEqual(renderGoalChange(change))
expect(JSON.stringify(context)).not.toContain('activation')
expect(events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'goal')).toHaveLength(0)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})
+865
View File
@@ -0,0 +1,865 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
import { HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import GoalService, {
GoalError,
GoalId,
decodeGoalChange,
foldGoal,
renderGoalChange,
} from '@deepseek-ai/dsh-goal'
import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
interface DeferredInjection {
content: ContentBlock[]
options: InjectOptions | undefined
}
interface StubAgent {
agent: Agent
session: Session
deferred: DeferredInjection[]
setDeferred(value: boolean): void
setStatus(value: AgentStatus): void
drain(): void
}
/** Number the next balanced one-shot injection turn. */
function nextTurn(session: Session): number {
return session.events.reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1
}
/** Mirror the public Agent.inject idle/open-turn contract for domain tests. */
function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void {
const source: MessageSource = options?.source ?? { kind: 'user' }
const context = {
content,
source,
...options?.meta === undefined ? {} : { meta: options.meta },
}
const last = session.events.at(-1)
const open = last !== undefined && last.type !== 'turn/end'
if (open) {
session.append('context/message', context, { surfaceOp: 'append' })
return
}
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', context, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
/** Build a registry-compatible agent around one concrete session. */
function stubAgentForSession(session: Session): StubAgent {
const id = session.id
const deferred: DeferredInjection[] = []
let shouldDefer = false
let status: AgentStatus = 'idle'
const agent: Agent = {
id,
options: {},
session,
ctx: new Context(),
get status() { return status },
send() {},
steer() {},
inject(content, options) {
if (shouldDefer) deferred.push({ content, options })
else appendInjection(session, content, options)
},
cancel() {},
whenIdle() { return Promise.resolve() },
}
return {
agent,
session,
deferred,
setDeferred(value) { shouldDefer = value },
setStatus(value) { status = value },
drain() {
shouldDefer = false
for (const injection of deferred.splice(0)) appendInjection(session, injection.content, injection.options)
},
}
}
/** Build a registry-compatible agent with controllable context deferral. */
function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[]): StubAgent {
return stubAgentForSession(new Session(SessionId(rawId), seed))
}
async function harness(config: { defaultMaxGoalRounds?: number } = {}) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService, config)
const stub = stubAgent(`goal-test-${Math.random()}`)
ctx.agents.register(stub.agent)
return { ctx, ...stub }
}
/** Append one admitted goal round as a balanced user-message turn. */
function appendRound(session: Session, ref: GoalRef, round: number): void {
const source = { kind: 'goal', goalId: ref.id, revision: ref.revision, round } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
session.append('user/message', { content: [{ type: 'text', text: `round ${round}` }], source }, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
describe('GoalService creation and replay', () => {
it('applies the configured default and writes one balanced verbatim context snapshot', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_700_000_000_000)
const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 })
const seen: string[] = []
ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) })
const goal = ctx.goals.create(agent, { objective: ' finish the feature ' })
expect(goal).toMatchObject({
objective: 'finish the feature',
phase: 'active',
revision: 1,
maxGoalRounds: 17,
roundsStarted: 0,
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
activation: 'armed',
})
expect(goal.id).toMatch(/^goal-/)
expect(seen).toEqual(['create'])
expect(session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end'])
const context = session.events[1]
expect(context?.type).toBe('context/message')
if (context?.type !== 'context/message') throw new Error('expected goal context')
expect(context.data.source).toEqual({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 })
const change = decodeGoalChange(context.data.meta)
if (change === undefined) throw new Error('expected decoded goal change')
expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } })
expect(context.data.content).toEqual(renderGoalChange(change))
expect(session.deriveMessages()).toEqual([{ role: 'user', content: context.data.content }])
expect(foldGoal(session.events)).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 })
vi.useRealTimers()
})
it('uses 256 rounds by default and validates create input inside create', async () => {
const { ctx, agent } = await harness()
expect(() => ctx.goals.create(agent, { objective: ' ' })).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_OBJECTIVE',
}))
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 0 })).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_MAX_ROUNDS',
}))
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError)
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 1.5 })).toThrow(HarnessError)
expect(() => ctx.goals.create(agent, {
objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1,
})).toThrow(GoalError)
expect(ctx.goals.create(agent, { objective: 'x' }).maxGoalRounds).toBe(256)
})
it('also resolves the default when constructed directly without Cordis config normalization', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const goals = new GoalService(ctx)
const stub = stubAgent('goal-direct-construction')
ctx.agents.register(stub.agent)
expect(goals.create(stub.agent, { objective: 'direct' })).toMatchObject({
objective: 'direct', maxGoalRounds: 256,
})
})
it('rejects invalid direct configuration', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await expect(ctx.plugin(GoalService, { defaultMaxGoalRounds: -1 })).rejects.toThrow(expect.objectContaining({
code: 'GOAL_INVALID_MAX_ROUNDS',
}))
})
it('restores a seeded goal and rounds with activation disarmed', async () => {
const first = await harness()
const created = first.ctx.goals.create(first.agent, { objective: 'seed me', maxGoalRounds: 9 })
appendRound(first.session, created, 1)
appendRound(first.session, created, 2)
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const resumed = stubAgent('seeded-goal', first.session.events)
ctx.agents.register(resumed.agent)
expect(ctx.goals.get(resumed.agent)).toMatchObject({
id: created.id,
roundsStarted: 2,
activation: 'disarmed',
})
})
it('inherits the completed-turn goal prefix through SessionStore.fork with child activation disarmed', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent')))
ctx.agents.register(parent.agent)
const goal = ctx.goals.create(parent.agent, { objective: 'inherit through fork', maxGoalRounds: 5 })
appendRound(parent.session, goal, 1)
const child = stubAgentForSession(ctx.sessions.fork(parent.session))
ctx.agents.register(child.agent)
expect(ctx.goals.get(child.agent)).toMatchObject({
id: goal.id,
objective: goal.objective,
roundsStarted: 1,
activation: 'disarmed',
})
expect(child.session.header.parentSession).toBe(parent.session.id)
expect(child.session.header.seedLength).toBe(parent.session.seq)
})
it('disarms live activation on every session-start edge', async () => {
const { ctx, agent, session } = await harness()
let goal = ctx.goals.create(agent, { objective: 'stay stopped after resume' })
expect(goal.activation).toBe('armed')
agentEvents(ctx, agent).emit('agent/session-start', 'resume')
expect(ctx.goals.get(agent)?.activation).toBe('disarmed')
goal = ctx.goals.resume(agent, goal)
expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 })
expect(() => foldGoal(session.events)).not.toThrow()
})
it('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)
const fiber = await ctx.plugin(GoalService)
const first = ctx.goals
const stub = stubAgent('goal-hmr')
ctx.agents.register(stub.agent)
const goal = first.create(stub.agent, { objective: 'survive service reload' })
await fiber.dispose()
expect(ctx.get('goals')).toBeUndefined()
agentEvents(ctx, stub.agent).emit('agent/session-start', 'resume')
expect(first.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'armed' })
await ctx.plugin(GoalService)
expect(ctx.goals).not.toBe(first)
expect(ctx.goals.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'disarmed' })
})
it('requires the exact live registry instance for reads and mutations', async () => {
const { ctx, agent } = await harness()
const impostor = { ...agent, session: new Session(agent.id) }
expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' }))
expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({
code: 'GOAL_AGENT_NOT_LIVE',
}))
})
it('rejects a disposed live object even before registry teardown', async () => {
const test = await harness()
test.setStatus('disposed')
expect(() => test.ctx.goals.get(test.agent)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' }))
})
})
describe('GoalService mutations', () => {
it('edits with compare-and-set revisions and rejects empty edits', async () => {
const { ctx, agent } = await harness()
const created = ctx.goals.create(agent, { objective: 'old', maxGoalRounds: 4 })
expect(() => ctx.goals.edit(agent, created, {})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_EDIT' }))
const objective = ctx.goals.edit(agent, created, { objective: ' new ' })
expect(objective).toMatchObject({ objective: 'new', maxGoalRounds: 4, revision: 2, activation: 'armed' })
expect(() => ctx.goals.edit(agent, created, { maxGoalRounds: 8 })).toThrow(expect.objectContaining({
code: 'GOAL_STALE_REVISION',
}))
const cap = ctx.goals.edit(agent, objective, { maxGoalRounds: 8 })
expect(cap).toMatchObject({ objective: 'new', maxGoalRounds: 8, revision: 3 })
expect(() => ctx.goals.edit(agent, cap, { objective: ' ' })).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_OBJECTIVE',
}))
})
it('supports pause, resume, block, and completion transitions', async () => {
const { ctx, agent } = await harness()
let goal = ctx.goals.create(agent, { objective: 'lifecycle' })
goal = ctx.goals.pause(agent, goal)
expect(goal).toMatchObject({ phase: 'paused', activation: 'disarmed', revision: 2 })
goal = ctx.goals.resume(agent, goal)
expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 3 })
goal = ctx.goals.block(agent, goal, { code: 'needs-input', message: 'A choice is required.' })
expect(goal).toMatchObject({
phase: 'blocked',
blockedReason: { code: 'needs-input', message: 'A choice is required.' },
activation: 'disarmed',
})
goal = ctx.goals.resume(agent, goal)
goal = ctx.goals.pause(agent, goal)
goal = ctx.goals.complete(agent, goal)
expect(goal).toMatchObject({ phase: 'complete', activation: 'disarmed' })
expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
})
it('allows completion from every stopped phase and replacement only after completion', async () => {
const phases = ['paused', 'blocked'] as const
for (const phase of phases) {
const { ctx, agent } = await harness()
let goal = ctx.goals.create(agent, { objective: phase })
goal = phase === 'paused'
? ctx.goals.pause(agent, goal)
: ctx.goals.block(agent, goal, { code: 'test-blocker', message: 'Blocked for the test.' })
const complete = ctx.goals.complete(agent, goal)
const replacement = ctx.goals.create(agent, { objective: `after ${phase}` })
expect(complete.phase).toBe('complete')
expect(replacement.id).not.toBe(complete.id)
expect(replacement.revision).toBe(1)
}
})
it('rejects replacement and invalid phase transitions while a resumable goal exists', async () => {
const { ctx, agent } = await harness()
const goal = ctx.goals.create(agent, { objective: 'still active' })
expect(() => ctx.goals.create(agent, { objective: 'replacement' })).toThrow(expect.objectContaining({
code: 'GOAL_ALREADY_EXISTS',
}))
expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
const paused = ctx.goals.pause(agent, goal)
expect(() => ctx.goals.pause(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
expect(() => ctx.goals.block(agent, paused, {
code: 'test-blocker', message: 'Blocked for the test.',
})).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_TRANSITION',
}))
})
it('records canonical blocker reasons and enforces the round cap on resume', async () => {
const { ctx, agent, session } = await harness()
let goal = ctx.goals.create(agent, { objective: 'bounded', maxGoalRounds: 2 })
for (const reason of [null, [], { code: 1, message: 'invalid code' }, { code: 'round-limit', message: 1 }]) {
expect(() => ctx.goals.block(agent, goal, reason as never)).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_BLOCK_REASON',
}))
}
expect(() => ctx.goals.block(agent, goal, {
code: 'Not Canonical', message: 'invalid code',
})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_BLOCK_REASON' }))
expect(() => ctx.goals.block(agent, goal, {
code: 'round-limit', message: ' ',
})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_BLOCK_REASON' }))
appendRound(session, goal, 1)
expect(ctx.goals.get(agent)?.roundsStarted).toBe(1)
appendRound(session, goal, 2)
goal = ctx.goals.block(agent, goal, { code: 'round-limit', message: ' Goal round limit reached. ' })
expect(goal).toMatchObject({
phase: 'blocked',
blockedReason: { code: 'round-limit', message: 'Goal round limit reached.' },
roundsStarted: 2,
activation: 'disarmed',
})
expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
goal = ctx.goals.edit(agent, goal, { maxGoalRounds: 3 })
expect(goal.blockedReason).toEqual({ code: 'round-limit', message: 'Goal round limit reached.' })
goal = ctx.goals.resume(agent, goal)
expect(goal).toMatchObject({ phase: 'active', maxGoalRounds: 3, activation: 'armed' })
expect(goal.blockedReason).toBeUndefined()
appendRound(session, goal, 3)
goal = ctx.goals.block(agent, goal, { code: 'round-limit', message: 'Goal round limit reached.' })
expect(ctx.goals.complete(agent, goal).phase).toBe('complete')
})
it('clears through a revisioned tombstone and permits a fresh goal', async () => {
const { ctx, agent, session } = await harness()
const goal = ctx.goals.create(agent, { objective: 'temporary' })
const tombstone = ctx.goals.clear(agent, goal)
expect(tombstone).toEqual({ id: goal.id, revision: 2 })
expect(ctx.goals.get(agent)).toBeUndefined()
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0, lastRef: tombstone })
expect(() => ctx.goals.clear(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_NOT_FOUND' }))
const next = ctx.goals.create(agent, { objective: 'fresh' })
expect(next.id).not.toBe(goal.id)
})
it('keeps per-goal mutation timestamps monotonic when the wall clock moves backward', async () => {
vi.useFakeTimers()
vi.setSystemTime(100)
const { ctx, agent, session } = await harness()
let goal = ctx.goals.create(agent, { objective: 'monotonic time' })
vi.setSystemTime(90)
goal = ctx.goals.pause(agent, goal)
expect(goal.updatedAt).toBe(100)
vi.setSystemTime(80)
ctx.goals.clear(agent, goal)
const clear = session.events
.filter(event => event.type === 'context/message')
.map(event => decodeGoalChange(event.data.meta))
.at(-1)
expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 })
expect(() => foldGoal(session.events)).not.toThrow()
vi.useRealTimers()
})
it('contains goal notification failures and preserves later listeners', async () => {
const { ctx, agent } = await harness()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const seen: string[] = []
ctx.on('goal/changed', () => { throw new Error('broken observer') })
ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) })
expect(ctx.goals.create(agent, { objective: 'notify' }).phase).toBe('active')
expect(seen).toEqual(['create'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer'))
})
it('preserves multiple pending revisions until deferred injections enter the log', async () => {
const test = await harness()
const { ctx, agent, session, deferred } = test
test.setDeferred(true)
let goal = ctx.goals.create(agent, { objective: 'deferred', maxGoalRounds: 5 })
goal = ctx.goals.edit(agent, goal, { objective: 'deferred edit' })
goal = ctx.goals.pause(agent, goal)
expect(goal).toMatchObject({ revision: 3, phase: 'paused', activation: 'disarmed' })
expect(deferred).toHaveLength(3)
expect(session.events).toHaveLength(0)
appendInjection(session, [{ type: 'text', text: 'unrelated' }], { source: { kind: 'plugin', plugin: 'test' } })
expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' })
test.drain()
expect(deferred).toHaveLength(0)
expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' })
expect(foldGoal(session.events)).toMatchObject({ goal: { revision: 3, phase: 'paused' } })
})
it('publishes a mutation consistently to a reentrant session observer', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer')))
ctx.agents.register(stub.agent)
let observed: ReturnType<GoalService['get']>
ctx.on('session/event', (session, event) => {
if (session === stub.session && event.type === 'context/message') observed = ctx.goals.get(stub.agent)
})
const created = ctx.goals.create(stub.agent, { objective: 'publish once' })
expect(observed).toEqual(created)
expect(ctx.goals.get(stub.agent)).toEqual(created)
expect(foldGoal(stub.session.events)).toMatchObject({ goal: { id: created.id, revision: 1 } })
})
it('rolls back a pending mutation when injection rejects before append', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const stub = stubAgent('goal-rejected-injection')
const append = stub.agent.inject.bind(stub.agent)
let reject = true
stub.agent.inject = (content, options) => {
if (reject) throw new Error('injection rejected')
append(content, options)
}
ctx.agents.register(stub.agent)
expect(() => ctx.goals.create(stub.agent, { objective: 'first attempt' })).toThrow('injection rejected')
reject = false
expect(ctx.goals.create(stub.agent, { objective: 'second attempt' })).toMatchObject({
objective: 'second attempt',
revision: 1,
})
})
it('rejects deferred goal mutations that enter the log out of FIFO order', async () => {
const test = await harness()
test.setDeferred(true)
const created = test.ctx.goals.create(test.agent, { objective: 'ordered' })
test.ctx.goals.edit(test.agent, created, { objective: 'ordered edit' })
const second = test.deferred[1]
if (second === undefined) throw new Error('expected a second deferred goal mutation')
appendInjection(test.session, second.content, second.options)
expect(() => test.ctx.goals.get(test.agent)).toThrow('advance the current goal')
})
it('observes a valid goal snapshot appended after an empty cache was established', async () => {
const { ctx, agent, session } = await harness()
expect(ctx.goals.get(agent)).toBeUndefined()
const change: GoalSnapshotChangeMeta = {
kind: 'goal/change',
version: 1,
operation: 'create',
goal: {
id: GoalId('goal-external'),
revision: 1,
objective: 'observe external append',
phase: 'active',
maxGoalRounds: 4,
},
roundsStarted: 0,
createdAt: 12,
updatedAt: 12,
}
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', {
content: renderGoalChange(change), source, meta: change as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
expect(ctx.goals.get(agent)).toMatchObject({
id: change.goal.id,
objective: change.goal.objective,
activation: 'disarmed',
})
})
it('reports the same corrupt unseen event after committing its valid prefix', async () => {
const { ctx, agent, session } = await harness()
expect(ctx.goals.get(agent)).toBeUndefined()
const change: GoalSnapshotChangeMeta = {
kind: 'goal/change',
version: 1,
operation: 'create',
goal: {
id: GoalId('goal-valid-prefix'),
revision: 1,
objective: 'valid prefix',
phase: 'active',
maxGoalRounds: 4,
},
roundsStarted: 0,
createdAt: 12,
updatedAt: 12,
}
appendInjection(session, renderGoalChange(change), {
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 },
meta: change as never,
})
appendInjection(session, [{ type: 'text', text: 'corrupt' }], {
source: { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 },
meta: { ...change, operation: 'edit', extra: true } as never,
})
expect(() => ctx.goals.get(agent)).toThrow('invalid shape')
expect(() => ctx.goals.get(agent)).toThrow('invalid shape')
})
})
describe('goal replay validation', () => {
function snapshotChange(overrides: Partial<GoalSnapshotChangeMeta> = {}): GoalSnapshotChangeMeta {
return {
kind: 'goal/change',
version: 1,
operation: 'create',
goal: {
id: GoalId('goal-validation'),
revision: 1,
objective: 'validate',
phase: 'active',
maxGoalRounds: 2,
},
roundsStarted: 0,
createdAt: 10,
updatedAt: 10,
...overrides,
}
}
function appendChange(
session: Session,
change: GoalChangeMeta,
overrides: { content?: ContentBlock[]; source?: MessageSource } = {},
): void {
const source = overrides.source ?? {
kind: 'goal',
goalId: change.operation === 'clear' ? change.cleared.id : change.goal.id,
revision: change.operation === 'clear' ? change.cleared.revision : change.goal.revision,
round: 0,
}
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', {
content: overrides.content ?? renderGoalChange(change),
source,
meta: change as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
function oneChange(change: GoalChangeMeta, overrides: { content?: ContentBlock[]; source?: MessageSource } = {}) {
const session = new Session(SessionId(`validation-${Math.random()}`))
appendChange(session, change, overrides)
return session.events
}
function mutation(
current: GoalSnapshotChangeMeta,
operation: Exclude<GoalSnapshotChangeMeta['operation'], 'create'>,
phase: GoalSnapshotChangeMeta['goal']['phase'],
overrides: Partial<GoalSnapshotChangeMeta> = {},
): GoalSnapshotChangeMeta {
return {
...current,
operation,
goal: {
id: current.goal.id,
revision: current.goal.revision + 1,
objective: current.goal.objective,
phase,
...phase === 'blocked'
? { blockedReason: { code: 'test-blocker', message: 'Blocked for replay validation.' } }
: {},
maxGoalRounds: current.goal.maxGoalRounds,
},
updatedAt: current.updatedAt + 1,
...overrides,
}
}
function foldPair(first: GoalSnapshotChangeMeta, second: GoalChangeMeta): ReturnType<typeof foldGoal> {
const session = new Session(SessionId(`validation-pair-${Math.random()}`))
appendChange(session, first)
appendChange(session, second)
return foldGoal(session.events)
}
it('ignores unrelated metadata and non-goal round sources', () => {
expect(decodeGoalChange(undefined)).toBeUndefined()
expect(decodeGoalChange({ kind: 'other' })).toBeUndefined()
const session = new Session(SessionId('unrelated'))
appendInjection(session, [{ type: 'text', text: 'other' }], {
source: { kind: 'plugin', plugin: 'test' },
meta: { kind: 'other' },
})
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 })
const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
session.append('user/message', { content: [{ type: 'text', text: 'ordinary' }], source }, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 })
})
it('rejects rounds attributed to another goal', () => {
const change = snapshotChange()
const session = new Session(SessionId('other-goal-round'), oneChange(change))
appendRound(session, { id: GoalId('goal-other'), revision: 1 }, 1)
expect(() => foldGoal(session.events)).toThrow('not the next admitted round')
})
it('rejects unsupported versions, operations, and top-level shapes', () => {
expect(() => decodeGoalChange({ ...snapshotChange(), version: 2 })).toThrow('unsupported goal change version')
expect(() => decodeGoalChange({ ...snapshotChange(), operation: 'explode' })).toThrow('operation is invalid')
expect(() => decodeGoalChange({ ...snapshotChange(), extra: true })).toThrow('snapshot change has an invalid shape')
expect(() => decodeGoalChange({
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'x', revision: 2 }, clearedAt: 1, extra: true,
})).toThrow('clear change has an invalid shape')
})
it('rejects invalid create and missing-current mutation sequences', () => {
const base = snapshotChange()
const invalidCreates: GoalSnapshotChangeMeta[] = [
{ ...base, goal: { ...base.goal, revision: 2 } },
{ ...base, goal: { ...base.goal, phase: 'paused' } },
{ ...base, roundsStarted: 1 },
]
for (const change of invalidCreates) expect(() => foldGoal(oneChange(change))).toThrow('goal create requires')
const edit = mutation(base, 'edit', 'active')
expect(() => foldGoal(oneChange(edit))).toThrow('requires a current goal')
const clear: GoalChangeMeta = {
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 12,
}
expect(() => foldGoal(oneChange(clear))).toThrow('clear requires a current goal')
const secondCreate = snapshotChange({
goal: { ...base.goal, id: GoalId('goal-second') },
createdAt: 20,
updatedAt: 20,
})
expect(() => foldPair(base, secondCreate)).toThrow('goal create requires')
})
it('rejects stale identity, counters, timestamps, and definition changes', () => {
const base = snapshotChange()
const invalid: GoalSnapshotChangeMeta[] = [
mutation(base, 'edit', 'active', { goal: { ...base.goal, id: GoalId('goal-wrong'), revision: 2 } }),
mutation(base, 'edit', 'active', { goal: { ...base.goal, revision: 3 } }),
mutation(base, 'edit', 'active', { createdAt: 11 }),
mutation(base, 'edit', 'active', { updatedAt: 9 }),
mutation(base, 'edit', 'active', { roundsStarted: 1 }),
mutation(base, 'pause', 'paused', {
goal: { ...base.goal, revision: 2, phase: 'paused', objective: 'changed illegally' },
}),
mutation(base, 'pause', 'paused', {
goal: { ...base.goal, revision: 2, phase: 'paused', maxGoalRounds: 3 },
}),
]
for (const change of invalid) expect(() => foldPair(base, change)).toThrow()
})
it('rejects invalid replayed lifecycle phase transitions', () => {
const base = snapshotChange()
const invalid: GoalSnapshotChangeMeta[] = [
mutation(base, 'edit', 'paused'),
mutation(base, 'pause', 'active'),
mutation(base, 'resume', 'paused'),
mutation(base, 'complete', 'active'),
mutation(base, 'block', 'active'),
]
for (const change of invalid) expect(() => foldPair(base, change)).toThrow()
const paused = mutation(base, 'pause', 'paused')
const exhausted = mutation(paused, 'resume', 'active', {
roundsStarted: 2,
goal: { ...paused.goal, revision: 3, phase: 'active', maxGoalRounds: 2 },
})
const session = new Session(SessionId('exhausted-resume'))
appendChange(session, base)
appendRound(session, base.goal, 1)
appendRound(session, base.goal, 2)
appendChange(session, { ...paused, roundsStarted: 2 })
appendChange(session, exhausted)
expect(() => foldGoal(session.events)).toThrow('exhausted round budget')
})
it('rejects invalid clear continuity and goal id reuse', () => {
const base = snapshotChange()
const staleClear: GoalChangeMeta = {
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 3 }, clearedAt: 11,
}
expect(() => foldPair(base, staleClear)).toThrow('advance the current goal')
const earlyClear: GoalChangeMeta = {
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 9,
}
expect(() => foldPair(base, earlyClear)).toThrow('timestamp cannot precede')
const complete = mutation(base, 'complete', 'complete')
const sameCurrentId = snapshotChange({
goal: { ...base.goal, revision: 1 },
createdAt: 20,
updatedAt: 20,
})
const completedSession = new Session(SessionId('reuse-complete'))
appendChange(completedSession, base)
appendChange(completedSession, complete)
appendChange(completedSession, sameCurrentId)
expect(() => foldGoal(completedSession.events)).toThrow('fresh active revision-one')
const second = snapshotChange({
goal: { ...base.goal, id: GoalId('goal-second') },
createdAt: 20,
updatedAt: 20,
})
const secondComplete = mutation(second, 'complete', 'complete')
const nonAdjacentReuse = new Session(SessionId('reuse-non-adjacent'))
appendChange(nonAdjacentReuse, base)
appendChange(nonAdjacentReuse, complete)
appendChange(nonAdjacentReuse, second)
appendChange(nonAdjacentReuse, secondComplete)
appendChange(nonAdjacentReuse, { ...sameCurrentId, createdAt: 30, updatedAt: 30 })
expect(() => foldGoal(nonAdjacentReuse.events)).toThrow('fresh active revision-one')
const clear: GoalChangeMeta = {
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 11,
}
const clearedSession = new Session(SessionId('reuse-clear'))
appendChange(clearedSession, base)
appendChange(clearedSession, clear)
appendChange(clearedSession, sameCurrentId)
expect(() => foldGoal(clearedSession.events)).toThrow('fresh active revision-one')
})
it('rejects goal-source context without matching durable metadata', () => {
const session = new Session(SessionId('goal-source-without-meta'))
const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', {
content: [{ type: 'text', text: 'missing' }], source,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
expect(() => foldGoal(session.events)).toThrow('lacks goal change metadata')
})
it('rejects malformed snapshots, refs, counters, and timestamps', () => {
const base = snapshotChange()
const badSnapshots: unknown[] = [
null,
{ ...base.goal, extra: true },
{ ...base.goal, id: '' },
{ ...base.goal, objective: ' ' },
{ ...base.goal, objective: ' padded ' },
{ ...base.goal, phase: 'unknown' },
{ ...base.goal, blockedReason: { code: 'unexpected', message: 'Only blocked goals have reasons.' } },
{ ...base.goal, phase: 'blocked' },
{ ...base.goal, phase: 'blocked', blockedReason: null },
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'test-blocker', message: 'Valid.', extra: true } },
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'NOT_CANONICAL', message: 'Bad code.' } },
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'test-blocker', message: ' padded ' } },
{ ...base.goal, revision: 0 },
{ ...base.goal, maxGoalRounds: -1 },
]
for (const goal of badSnapshots) expect(() => decodeGoalChange({ ...base, goal })).toThrow()
expect(() => decodeGoalChange({ ...base, roundsStarted: -1 })).toThrow('roundsStarted')
expect(() => decodeGoalChange({ ...base, createdAt: -1 })).toThrow('createdAt')
expect(() => decodeGoalChange({ ...base, updatedAt: 9 })).toThrow('cannot precede')
expect(() => decodeGoalChange({
kind: 'goal/change', version: 1, operation: 'clear', cleared: null, clearedAt: 1,
})).toThrow('tombstone')
expect(() => decodeGoalChange({
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: '', revision: 1 }, clearedAt: 1,
})).toThrow('non-empty')
expect(() => decodeGoalChange({
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'x', revision: 0 }, clearedAt: 1,
})).toThrow('positive safe integer')
})
it('rejects source and content drift from the durable metadata', () => {
const change = snapshotChange()
expect(() => foldGoal(oneChange(change, { source: { kind: 'plugin', plugin: 'wrong' } }))).toThrow('mismatched source')
expect(() => foldGoal(oneChange(change, {
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: -1 },
}))).toThrow('source is invalid')
expect(() => foldGoal(oneChange(change, { content: [{ type: 'text', text: 'wrong' }] }))).toThrow('model-visible content')
})
it('folds a clear tombstone after a snapshot', () => {
const change = snapshotChange()
const session = new Session(SessionId('fold-clear'), oneChange(change))
const clear: GoalChangeMeta = {
kind: 'goal/change',
version: 1,
operation: 'clear',
cleared: { id: change.goal.id, revision: 2 },
clearedAt: 20,
}
const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', {
content: renderGoalChange(clear), source, meta: clear as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
expect(foldGoal(session.events)).toEqual({
roundsStarted: 0,
lastRef: { id: change.goal.id, revision: 2 },
})
})
})
+36
View File
@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../core/scope"
},
{
"path": "../../core/agent"
}
]
}
+76
View 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.
+46
View File
@@ -0,0 +1,46 @@
{
"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"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-goal": "^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-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
View 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
View 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,
),
}))
}
@@ -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,
})
})
})
+39
View File
@@ -0,0 +1,39 @@
{
"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"
}
]
}
+1 -1
View File
@@ -6,7 +6,7 @@ The package owns the builtin typed-spec catalog, provider/app behavior entities,
All business and document validation completes before commit writes any affected file. Commit detects external edits made after the session opened, but deliberately provides no cross-file rollback after writing starts.
Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, timeout policy, and ask-user. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator.
Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, timeout policy, and ask-user. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. The ACP app option contributes the human-command and user-interaction services before the bridge.
`SdkProject.open()` requires only readable root `package.json` and `cordis.yml`. A Cordis config entry anchors feature installation; a package present only through a linked NPM dependency closure leaves the feature absent. Once an owned Cordis config entry exists, an incomplete resource shape is `inconsistent` and cannot be modified automatically.
@@ -73,6 +73,10 @@ class AppOption extends FeatureOption {
case 'acp':
return new ProjectContribution([
...appProjectResources(profile, this.id),
...npmCordisConfigEntry(ID, {
id: 'commands',
name: '@deepseek-ai/dsh-commands',
}),
...npmCordisConfigEntry(ID, {
id: 'user-interaction',
name: '@deepseek-ai/dsh-user-interaction',
@@ -283,6 +283,7 @@ describe('SdkProject and ProjectEditSession', () => {
edit.configureFeature(registry.get(featureId('app')), selection('app', ['acp']))
const acp = (await edit.commit()).project
expect(acp.profile.runInterface).toBe('acp')
expect(acp.cordis.entry('commands')).toMatchObject({ name: '@deepseek-ai/dsh-commands' })
expect(acp.packageManifest().scripts).toMatchObject({
dev: 'dsh-sdk dev index.ts',
start: 'dsh-sdk start index.js',
+28 -5
View File
@@ -18,8 +18,9 @@
import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { tmpdir } from 'node:os'
import { join, delimiter } from 'node:path'
import { basename, dirname, join, delimiter } from 'node:path'
import {
ClientSideConnection,
PROTOCOL_VERSION,
@@ -41,12 +42,15 @@ export type { AgentUnderTest } from './launcher.ts'
* the client observes the selected update (`agent_message_chunk` by default),
* then cancels and awaits completion. A named `waitForToolCallUpdate` keeps the
* step open for a terminal tool update that may follow the prompt response.
* `promptAndWaitForAgentMessage` arms an exact text-chunk waiter before sending
* the prompt, then keeps the application live until that later update arrives.
*/
export type InputStep =
| { op: 'initialize'; terminalOutput?: boolean }
| { op: 'newSession' }
| { op: 'newSessionExpectError'; additionalDirectories?: string[] }
| { op: 'prompt'; text: string }
| { op: 'promptAndWaitForAgentMessage'; text: string; waitForText: string }
| { op: 'promptExpectError'; text: string }
| {
op: 'promptAndCancel'
@@ -150,12 +154,20 @@ export interface RunOptions {
}
/**
* Return a fixed-length spill root across POSIX and Windows after Windows adds its drive prefix.
* Derive one stable, fixed-length spill root owned by this scenario.
* Windows uses a two-character-shorter root because drive resolution adds its drive prefix.
* @param fixtureFile - The scenario fixture whose parent directory provides the stable identity.
* @param platform - the host platform, injectable for unit coverage.
* @returns the root-relative snapshot spill directory.
*/
export function snapshotSpillRoot(platform: NodeJS.Platform = process.platform): string {
return platform === 'win32' ? '/t/dsh-acp-snapshot-spill' : '/tmp/dsh-acp-snapshot-spill'
export function snapshotSpillRoot(
fixtureFile: string,
platform: NodeJS.Platform = process.platform,
): string {
const scenario = basename(dirname(fixtureFile))
const key = createHash('sha256').update(scenario).digest('hex').slice(0, 9)
const root = platform === 'win32' ? '/t' : '/tmp'
return `${root}/dsh-acp-snap-${key}`
}
/**
@@ -172,7 +184,9 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-'))
// Fixed path length: spill-policy budgets the preview against the REAL path
// before stdout normalization, so tmpdir() length differences churn expected outputs.
const spillRoot = snapshotSpillRoot()
// Scenario ownership also matters: replay runs concurrently, and one teardown
// must never delete another scenario's in-flight full-output recovery file.
const spillRoot = snapshotSpillRoot(opts.fixtureFile)
// Everything past the temp-dir creation is followed by failure-safe cleanup,
// so a failure in workspace seeding, spawn, or any step never leaks resources.
let launched: LaunchedAcpTestAgent | undefined
@@ -340,6 +354,15 @@ async function runStep(
await client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] })
return
}
case 'promptAndWaitForAgentMessage': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: promptAndWaitForAgentMessage before newSession')
const updateDone = waitForUpdate(update => update.sessionUpdate === 'agent_message_chunk'
&& update.content.type === 'text' && update.content.text === step.waitForText)
await client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] })
await updateDone
return
}
case 'promptExpectError': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: promptExpectError before newSession')
@@ -25,7 +25,7 @@ const LOCAL_SPILL_PATH_RE = new RegExp(
'g',
)
const SNAPSHOT_SPILL_PATH_RE = new RegExp(
String.raw`(?:[A-Za-z]:)?[\\/](?:tmp|t)[\\/]dsh-acp-snapshot-spill[\\/]session-[0-9a-f]{12}[\\/][0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
String.raw`(?:[A-Za-z]:)?[\\/](?:tmp|t)[\\/](?:dsh-acp-snap-[0-9a-f]{9}|dsh-acp-snapshot-spill)[\\/]session-[0-9a-f]{12}[\\/][0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
+ String.raw`(?=\. Use read with offset/limit|[\s)]|$)`,
'g',
)
@@ -152,6 +152,7 @@ async function handlePrompt(id: number | string): Promise<void> {
mode: process.env.DSH_SNAPSHOT,
override: process.env.DSH_SNAPSHOT_OVERRIDE ?? null,
childFiles: process.env.DSH_SNAPSHOT_CHILD_FILES ?? null,
spillRoot: process.env.DSH_SNAPSHOT_SPILL_ROOT ?? null,
})}`)
}
if (behavior.echoWorkspace === true) {
@@ -60,11 +60,24 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s
const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }]
it('keeps the resolved snapshot spill root length stable across platforms', () => {
expect(snapshotSpillRoot('linux')).toBe('/tmp/dsh-acp-snapshot-spill')
expect(snapshotSpillRoot('win32')).toBe('/t/dsh-acp-snapshot-spill')
it('keeps scenario-owned snapshot spill root length stable across platforms', () => {
const fixtureFile = '/fixtures/scenario/session.jsonl'
const posix = snapshotSpillRoot(fixtureFile, 'linux')
const windows = snapshotSpillRoot(fixtureFile, 'win32')
expect(posix).toMatch(/^\/tmp\/dsh-acp-snap-[0-9a-f]{9}$/)
expect(windows).toMatch(/^\/t\/dsh-acp-snap-[0-9a-f]{9}$/)
expect(windows.length + 2).toBe(posix.length)
})
function environmentEcho(rawStdout: string): Record<string, unknown> {
const frames = rawStdout.trim().split('\n')
.map(line => JSON.parse(line) as { params?: { update?: { content?: { text?: unknown } } } })
const text = frames.map(frame => frame.params?.update?.content?.text)
.find(value => typeof value === 'string' && value.startsWith('env:'))
if (typeof text !== 'string') throw new Error('fake ACP agent did not echo its environment')
return JSON.parse(text.slice('env:'.length)) as Record<string, unknown>
}
describe('runScenario', () => {
it('surfaces an asynchronous child spawn failure through startup and close', async () => {
const { dir } = await scenario({})
@@ -422,6 +435,22 @@ describe('runScenario', () => {
expect(env.childFiles).toBe(childFiles.join(delimiter))
})
it('gives concurrent scenarios distinct equal-length spill roots', { timeout: 20_000 }, async () => {
const [first, second] = await Promise.all([scenario({ echoEnv: true }), scenario({ echoEnv: true })])
const results = await Promise.all([first, second].map(({ fixtureFile }) => runScenario(
{ steps: [...boot, { op: 'prompt', text: 'env?' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)))
const roots = results.map(result => environmentEcho(result.rawStdout).spillRoot)
expect(roots.every(root => typeof root === 'string')).toBe(true)
expect(new Set(roots).size).toBe(2)
expect((roots[0] as string).length).toBe((roots[1] as string).length)
expect(roots).toEqual([
snapshotSpillRoot(first.fixtureFile),
snapshotSpillRoot(second.fixtureFile),
])
})
it('seeds the workspace dir into the temp cwd before the run', { timeout: 20_000 }, async () => {
const { dir, fixtureFile } = await scenario({ echoWorkspace: true })
const workspaceDir = join(dir, 'workspace')
@@ -447,6 +476,21 @@ describe('runScenario', () => {
expect(result.rawStdout.indexOf('thinking about it')).toBeLessThan(result.rawStdout.indexOf('cancelled'))
})
it('promptAndWaitForAgentMessage keeps the app live through a matching later update', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ prompt: 'respond' })
const result = await runScenario(
{
steps: [...boot, {
op: 'promptAndWaitForAgentMessage',
text: 'go',
waitForText: 'thinking about it',
}],
},
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.rawStdout).toContain('thinking about it')
})
it('promptAndCancel can bracket cancellation with tool-call updates', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({
prompt: 'hang-until-cancel',
@@ -556,6 +600,7 @@ describe('runScenario', () => {
it.each([
[{ op: 'prompt', text: 'x' }, /prompt before newSession/],
[{ op: 'promptAndWaitForAgentMessage', text: 'x', waitForText: 'later' }, /promptAndWaitForAgentMessage before newSession/],
[{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/],
[{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/],
[{ op: 'cancel' }, /cancel before newSession/],
@@ -189,19 +189,34 @@ describe('normalizeSessionLog', () => {
expect(out).not.toContain('/tmp/dsh-acp-snapshot-spill')
})
it('scrubs fixed snapshot spill paths with Windows drive and separators', () => {
it('scrubs scenario-owned snapshot spill paths', () => {
const ev = JSON.stringify({
type: 'tool/result', seq: 2, time: 5,
data: {
content: [{
type: 'text',
text: String.raw`Full formatted result stored at: C:\t\dsh-acp-snapshot-spill\session-c22bc3f1d2af\8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`,
text: 'Full formatted result stored at: /tmp/dsh-acp-snap-012345678/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.',
}],
},
})
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
expect(out).toContain('{{spillLocator:bash.txt}}')
expect(out).not.toContain('C:\\t\\dsh-acp-snapshot-spill')
expect(out).not.toContain('/tmp/dsh-acp-snap-012345678')
})
it('scrubs scenario-owned snapshot spill paths with Windows drive and separators', () => {
const ev = JSON.stringify({
type: 'tool/result', seq: 2, time: 5,
data: {
content: [{
type: 'text',
text: String.raw`Full formatted result stored at: C:\t\dsh-acp-snap-012345678\session-c22bc3f1d2af\8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`,
}],
},
})
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
expect(out).toContain('{{spillLocator:bash.txt}}')
expect(out).not.toContain('C:\\t\\dsh-acp-snap-012345678')
})
it('shares cwd-rooted path handling with stdout normalization', () => {
+1
View File
@@ -30,6 +30,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
@@ -8,6 +8,7 @@
import type { Events } from 'cordis'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type {} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-goal'
import type {} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-subagent'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -27,6 +28,7 @@ function adapt<K extends ScopedEventName>(
}
const scopedSubjectResolvers = Object.freeze({
'agent/cancel-requested': adapt<'agent/cancel-requested'>(args => args[0]),
'agent/created': adapt<'agent/created'>(args => args[0]),
'agent/disposed': adapt<'agent/disposed'>(args => args[0]),
'agent/error': adapt<'agent/error'>(args => args[0]),
@@ -43,6 +45,7 @@ const scopedSubjectResolvers = Object.freeze({
'agent/turn-continuation': adapt<'agent/turn-continuation'>(args => args[0]),
'agent/turn-stop': adapt<'agent/turn-stop'>(args => args[0]),
'approval/request': adapt<'approval/request'>(args => args[0].agent),
'goal/changed': adapt<'goal/changed'>(args => args[0]),
'session/created': null,
'session/disposed': null,
'session/event': null,
@@ -936,6 +936,7 @@ describe('scoped-dispatch invariants', () => {
['agent/turn-stop', [agent, 1]],
['agent/error', [agent, 1, 0, new Error('x')]],
['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]],
['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]],
['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]],
['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
@@ -23,6 +23,9 @@
{
"path": "../../core/agent"
},
{
"path": "../../goal/goal"
},
{
"path": "../../core/scope"
},
+2 -1
View File
@@ -5,6 +5,7 @@ Integrations that expose the agent to an external editor or client. These are **
| Package | Role | ctx key |
|---|---|---|
| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
| `commands/` | Human-command registry: shared discovery metadata, scoped shadowing, cancellation, and direct UI dispatch | `ctx.commands` |
| `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` |
| `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
@@ -13,7 +14,7 @@ Integrations that expose the agent to an external editor or client. These are **
| `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) |
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door; non-interactive tasks use the headless `cli-demo` app instead of a UI channel.
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door; non-interactive tasks use the headless `cli-demo` app instead of a UI channel. [`commands`](commands/README.md) is the human-only discovery and dispatch plane shared by TUI and ACP; command input and output do not become model messages.
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.
+26 -5
View File
@@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the terminal `
`apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface.
The plugin injects `agents`, `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms.
The plugin injects `agents`, [`commands`](../commands/README.md), `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the command registry backs slash discovery and direct dispatch; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms.
### Config
@@ -26,10 +26,10 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
| ACP method | Harness seam | Notes |
|---|---|---|
| `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` |
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, and replays user, assistant, and tool events |
| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) |
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, and tool events, and re-advertises commands |
| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; unsupported content and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC |
| `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another |
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, and tool render intents |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
| `session/request_permission` | `approval/request` listener | answers one-shot allow/reject requests for bridge-owned calls; foreign or call-less requests delegate and fail closed if unanswered — see "Permission prompts" |
@@ -39,6 +39,12 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
One id-keyed record map plus exact agent-object checks route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session Agent Note](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md).
## Human commands
After `session/new` and `session/load`, the bridge emits ACP's full `available_commands_update` snapshot for that exact agent. A new session's server-generated id is introduced by the RPC response before its snapshot enters the connection write queue. A global or scoped registry change refreshes every live session from its independently resolved view, so clients replace rather than merge cached catalogs. Names omit the slash; descriptions and optional unstructured-input hints map directly to ACP `AvailableCommand`.
ACP v1 permits a command prompt to carry additional content blocks. The bridge applies its ordinary lossless flattening for supported `text` and `resource_link` blocks, then dispatches when the result begins with `/`. Known commands execute without a model request. Unknown or malformed slash input returns a direct error instead of falling back to the model; prefix whitespace when literal slash-leading text must reach the model. Expected handler errors, thrown failures, and successful text stream as UI-only `agent_message_chunk` output and end the request; cancellation returns `cancelled`. See the [command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) and the [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands).
## Session config options
The bridge advertises a `model`-category select in `session/new` and `session/load` when the session has a complete target whose provider is registered. Values encode the complete provider/model pair, are grouped by provider when more than one group is available, and come from `ctx.llm.listProviders()` / `listModels()`. The configured or last-requested model is added when absent because catalogs are advisory and private adapters may accept unlisted ids. A selection changes only that ACP session. Agent-scoped prompt assembly snapshots the selected pair for one step, supplies matching `{{provider}}` / `{{model}}` variables, and the `agent/request` waterfall applies the same pair; a concurrent selection therefore takes effect on the next step instead of splitting prompt text from routing. The resulting request header is the durable record restored by `session/load`; a selection never used by a request remains in-memory only.
@@ -108,6 +114,20 @@ Prompt tokens are data-dependent and remain in that session's history until comp
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Human commands
#### What the model sees
Nothing from command discovery, slash input, or command output. A command handler may separately mutate a durable domain whose later state affects model requests.
#### Token effect
Direct dispatch adds no model tokens and no session message. The mutated domain owns any later prompt or history cost.
#### KV Cache effect
Command discovery, dispatch, and direct output never enter a model request and do not affect its cache. A mutated domain owns any later cache effect.
### Human answers and permission decisions
#### What the model sees
@@ -170,3 +190,4 @@ Loading does not rewrite the stored log, but the next request is reconstructed u
- **Prompt content is `text` + `resource_link` only** — image, audio, and embedded-resource blocks are rejected, as is a non-empty `mcpServers` list at `session/new`.
- **Terminal cards render completed output** — live incremental streaming and command classification are named follow-ups of [the terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
- **Permission answers are one-shot only** — the bridge offers `allow_once` / `reject_once`; durable `allow_always` grants and their storage/revocation policy remain deferred to the approval seam.
- **Command output is live-only** — discovery is refreshed after load, but direct command results are not persisted or replayed into a reconnected editor.
+8 -9
View File
@@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th
## At a glance
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough**, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
## 1. Agent methods (client → agent)
@@ -23,8 +23,8 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
| `session/load` | S | ✅ | ✅ | ✅ | Maps to `agents.resume` + full event-log replay; validates persisted `cwd` before constructing the agent. |
| `session/resume` | S | ❌ | ✅ | ✅ | Reconnect WITHOUT replay; gated by `sessionCapabilities.resume`. Not advertised. |
| `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. |
| `session/prompt` | S | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. |
| `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. |
| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.send`. One request is in flight per session. |
| `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. |
| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement and modes are slated for removal in ACP v2 (see [§6](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. |
| model selection | S | ✅ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. Values preserve the provider/model pair, catalogs come from `ctx.llm`, selection is per session, and `session/load` restores the last requested pair. Codex also supports the legacy `unstable_setSessionModel` ext method. |
@@ -84,7 +84,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `tool_call` | S | ✅ | ✅ | ✅ | Tool-owned presentation (`presentCall`); see [§5](#5-tool-call-rendering). |
| `tool_call_update` | S | ✅ | ✅ | ✅ | From appended `tool/result` via `presentResult`; replacement results rewrite model context and do not duplicate or overwrite execution presentation. |
| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated``{ sessionUpdate: 'plan', entries }`). |
| `available_commands_update` | S | | ✅ | ✅ | No slash commands advertised. |
| `available_commands_update` | S | | ✅ | ✅ | Full effective snapshot after create/load and registry changes; names, descriptions, and unstructured-input hints come from `ctx.commands`. |
| `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. |
| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox Agent Note § Per-session mode switching](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). |
| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). |
@@ -142,11 +142,10 @@ Ranked by how commonly the reference adapters ship them and how much UX they unl
1. **Session lifecycle**`session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`.
2. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
3. **Slash commands** (`available_commands_update`).
4. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
5. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
6. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
7. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
3. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
4. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
5. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
6. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
## Out of scope
+2
View File
@@ -29,6 +29,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-permission": "^0.0.1",
@@ -47,6 +48,7 @@
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
+147 -7
View File
@@ -17,7 +17,9 @@ import {
PROTOCOL_VERSION,
RequestError,
type Agent as AcpAgent,
type AnyMessage,
type AuthenticateRequest,
type AvailableCommand,
type CancelNotification,
type ContentBlock as AcpContentBlock,
type CreateElicitationRequest,
@@ -46,6 +48,7 @@ import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-commands'
import { SessionId } from '@deepseek-ai/dsh-session'
// Side-effect type import: resolves `ctx.get('permission')` to the service.
import type {} from '@deepseek-ai/dsh-permission'
@@ -77,13 +80,50 @@ import {
export const name = 'acp'
// Interface services back loading, presentation, interaction, and prompt assembly.
export const inject = ['agents', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt']
export const inject = ['agents', 'commands', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt']
/** Preserve invalid-parameter detail in the SDK wire error message. */
function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
}
/** Render arbitrary thrown values without trusting their string coercion. */
function renderThrown(value: unknown): string {
try {
return String(value)
} catch {
return '<unrenderable thrown value>'
}
}
/** Return a server-created session id carried by an outbound success response. */
function responseSessionId(message: AnyMessage): SessionId | undefined {
if (!('result' in message) || typeof message.result !== 'object' || message.result === null
|| !('sessionId' in message.result) || typeof message.result.sessionId !== 'string') {
return undefined
}
return SessionId(message.result.sessionId)
}
/** Observe messages only after the wrapped ACP transport has written them. */
function observeOutbound(stream: Stream, onWritten: (message: AnyMessage) => void): Stream {
const writer = stream.writable.getWriter()
return {
readable: stream.readable,
writable: new WritableStream<AnyMessage>({
async write(message) {
await writer.write(message)
onWritten(message)
},
/* v8 ignore start -- the ACP SDK never closes or aborts its outbound stream;
preserve the wrapped Stream contract for other consumers nonetheless */
close: () => writer.close(),
abort: (reason: unknown) => writer.abort(reason),
/* v8 ignore stop */
}),
}
}
/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */
function internalError(detail: string): RequestError {
return RequestError.internalError(undefined, detail)
@@ -260,6 +300,8 @@ interface SessionRecord {
reject: (error: Error) => void
turn: number | undefined
} | undefined
/** Abort owner for a direct slash-command request, mutually exclusive with `inflight`. */
commandAbort: AbortController | undefined
/** Last idle switch per knob, anchored before the next prompt assembles. */
pendingSwitches: { preset?: string }
}
@@ -274,6 +316,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
// ACP handlers execute outside this plugin's injection scope, so capture
// injected services during apply(); lazy service reads in a handler fail.
const agents = ctx.agents
const commands = ctx.commands
const llm = ctx.llm
const sessionPersistence = ctx.sessionPersistence
const logger = ctx.logger
@@ -380,6 +423,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
const sessions = new Map<SessionId, SessionRecord>()
// Reserve an id before resume so pipelined load/new requests cannot duplicate it.
const loadingIds = new Set<SessionId>()
// A new-session response introduces its server-generated id to the client;
// keep its initial command snapshot pending until that response is written.
const pendingCommandSnapshots = new Map<SessionId, SessionRecord>()
// Async creation checks this after awaits to avoid publishing after teardown.
let closed = false
// Each new or loaded session snapshots the latest connection capability.
@@ -468,6 +514,43 @@ export function apply(ctx: Context, config: AcpConfig): void {
})
}
/** Project the effective registry view onto ACP discovery metadata. */
const availableCommands = (agent: Agent): AvailableCommand[] => commands.list(agent).map(command => ({
name: command.name,
description: command.description,
...command.input === undefined ? {} : { input: { hint: command.input.hint } },
}))
/** Push the protocol's full-snapshot command catalog for one live session. */
const notifyCommands = (rec: SessionRecord): void => {
notify({
sessionId: rec.agent.session.id,
update: {
sessionUpdate: 'available_commands_update',
availableCommands: availableCommands(rec.agent),
},
})
}
/** Enqueue a new session's first command snapshot behind its written RPC response. */
const announceInitialCommands = (message: AnyMessage): void => {
const sessionId = responseSessionId(message)
if (sessionId === undefined) return
const rec = pendingCommandSnapshots.get(sessionId)
if (rec === undefined) return
pendingCommandSnapshots.delete(sessionId)
notifyCommands(rec)
}
// Registration and HMR removal can affect global or one scoped view; refresh
// every announced bridge-owned session and let the registry resolve each
// exact agent. A pending new-session snapshot will read the latest registry.
ctx.on('commands/change', () => {
for (const rec of sessions.values()) {
if (!pendingCommandSnapshots.has(rec.agent.session.id)) notifyCommands(rec)
}
})
/** Settle the in-flight prompt with a stop reason, exactly once (no-op if none pending). */
const settlePrompt = (rec: SessionRecord, reason: StopReason): void => {
const inflight = rec.inflight
@@ -674,15 +757,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
await handle.dispose()
throw internalError('connection closed during session/new')
}
sessions.set(sessionId, {
const record: SessionRecord = {
agent: handle.agent,
dispose: () => handle.dispose(),
presenter: makePresenter(handle.agent),
terminalEnabled: terminalOutputCap,
target,
inflight: undefined,
commandAbort: undefined,
pendingSwitches: {},
})
}
sessions.set(sessionId, record)
pendingCommandSnapshots.set(sessionId, record)
const configOptions = configOptionsFor(handle.agent, directory)
return { sessionId, ...configOptions.length > 0 ? { configOptions } : {} }
},
@@ -763,6 +849,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
terminalEnabled,
target,
inflight: undefined,
commandAbort: undefined,
pendingSwitches: {},
}
sessions.set(sessionId, record)
@@ -787,6 +874,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
for (const event of agent.session.events) {
streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal)
}
notifyCommands(record)
const configOptions = configOptionsFor(agent, directory)
return configOptions.length > 0 ? { configOptions } : {}
} finally {
@@ -797,7 +885,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
async prompt(params: PromptRequest): Promise<PromptResponse> {
assertOpen()
const rec = requireSession(SessionId(params.sessionId))
if (rec.inflight !== undefined) {
if (rec.inflight !== undefined || rec.commandAbort !== undefined) {
throw invalidParams('a prompt is already in flight for this session')
}
if (promptHasUnsupportedContent(params.prompt)) {
@@ -810,6 +898,52 @@ export function apply(ctx: Context, config: AcpConfig): void {
// waiting for a settle that never comes.
throw invalidParams('empty prompt')
}
// ACP command prompts may carry additional supported content blocks.
// The same lossless flattening used for model prompts supplies their
// unstructured command input; unsupported kinds were rejected above.
const commandLine = text.startsWith('/') ? text : undefined
if (commandLine !== undefined) {
const controller = new AbortController()
rec.commandAbort = controller
try {
const result = await commands.execute(rec.agent, commandLine, controller.signal)
if (result !== undefined && result.text !== undefined && result.text !== '') {
notify({
sessionId: rec.agent.session.id,
update: {
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: result.kind === 'error' ? `Error: ${result.text}` : result.text,
},
},
})
} else if (result === undefined) {
notify({
sessionId: rec.agent.session.id,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: `Error: unknown command: ${commandLine}` },
},
})
}
return { stopReason: 'end_turn' }
} catch (error: unknown) {
if (controller.signal.aborted) return { stopReason: 'cancelled' }
const rendered = renderThrown(error)
logger.warn(`acp: command failed: ${rendered}`)
notify({
sessionId: rec.agent.session.id,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: `Error: command failed: ${rendered}` },
},
})
return { stopReason: 'end_turn' }
} finally {
rec.commandAbort = undefined
}
}
// Install the in-flight slot BEFORE send() (send does not synchronously
// flip status to running; the session/event listener records the turn
// number and settle/rejects it). Capture the log length now as the
@@ -837,8 +971,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
// settle it, because cancel() may drop the turn before any turn/end is
// emitted, and removing this direct settle would move the RPC's
// resolution onto a later observer path, changing its timing.
rec.agent.cancel('session/cancel')
settlePrompt(rec, 'cancelled')
if (rec.commandAbort !== undefined) {
rec.commandAbort.abort(new Error('session/cancel'))
} else {
rec.agent.cancel('session/cancel')
settlePrompt(rec, 'cancelled')
}
return Promise.resolve()
},
@@ -908,7 +1046,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
)
conn = new AgentSideConnection(makeAgent, stream)
conn = new AgentSideConnection(makeAgent, observeOutbound(stream, announceInitialCommands))
/**
* Tear ALL live sessions down to quiescence (docs/defensive-patterns.md "dispose must reach
@@ -946,12 +1084,14 @@ export function apply(ctx: Context, config: AcpConfig): void {
// installed yet) must observe this after its await and refuse to install a
// post-teardown record. Set even when there are no live sessions.
closed = true
pendingCommandSnapshots.clear()
const recs = [...sessions.values()]
sessions.clear()
if (recs.length === 0) return Promise.resolve()
quiescing = (async () => {
await Promise.all(recs.map(async (rec) => {
settlePrompt(rec, 'cancelled')
rec.commandAbort?.abort(new Error('ACP connection closed'))
// Per-agent dispose (the AgentHandle disposer): unregister this agent,
// stop its loop (sets disposed + aborts the in-flight step), await
// quiescence (the loop exit + final flush), and remove its session — so
+276
View File
@@ -0,0 +1,276 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
function commandUpdates(harness: BridgeHarness, sessionId: string) {
return harness.sessionUpdates.filter(update => update.sessionId === sessionId
&& update.update.sessionUpdate === 'available_commands_update')
}
function messageText(harness: BridgeHarness, sessionId: string): string {
return harness.sessionUpdates
.filter(update => update.sessionId === sessionId && update.update.sessionUpdate === 'agent_message_chunk')
.map(({ update }) => update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text'
? update.content.text : '')
.join('')
}
describe('ACP plugin commands', () => {
let storageDir: string
let harness: BridgeHarness | undefined
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-command-')) })
afterEach(async () => {
if (harness !== undefined) await harness.dispose()
harness = undefined
await rm(storageDir, { recursive: true, force: true })
})
it('publishes a full command snapshot after session creation and refreshes it dynamically', async () => {
harness = await makeBridgeHarness({ storageDir })
harness.ctx.commands.register({
name: 'inspect',
description: 'Inspect the session',
input: { hint: '<target>' },
handler: () => ({ kind: 'success' }),
})
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await vi.waitFor(() => {
expect(commandUpdates(harness!, sessionId).at(-1)?.update).toEqual({
sessionUpdate: 'available_commands_update',
availableCommands: [{
name: 'inspect',
description: 'Inspect the session',
input: { hint: '<target>' },
}],
})
})
const dispose = harness.ctx.commands.register({
name: 'alpha',
description: 'Alpha command',
handler: () => ({ kind: 'success' }),
})
await vi.waitFor(() => {
expect(commandUpdates(harness!, sessionId).at(-1)?.update).toMatchObject({
availableCommands: [{ name: 'alpha' }, { name: 'inspect' }],
})
})
dispose()
await vi.waitFor(() => {
expect(commandUpdates(harness!, sessionId).at(-1)?.update).toMatchObject({
availableCommands: [{ name: 'inspect' }],
})
})
})
it('re-advertises commands after loading a persisted session', async () => {
const live = await makeBridgeHarness({ storageDir, script: [textResponse('persisted')] })
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist this session' }] })
await live.dispose()
harness = await makeBridgeHarness({ storageDir })
harness.ctx.commands.register({
name: 'loaded', description: 'Loaded command', handler: () => ({ kind: 'success' }),
})
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await harness.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
expect(commandUpdates(harness, sessionId).at(-1)?.update).toMatchObject({
availableCommands: [{ name: 'loaded', description: 'Loaded command' }],
})
})
it('coalesces registry changes before a new session command snapshot is announced', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
harness.ctx.commands.register({
name: 'raced', description: 'Registered after the response', handler: () => ({ kind: 'success' }),
})
await vi.waitFor(() => {
expect(commandUpdates(harness!, sessionId)).toHaveLength(1)
expect(commandUpdates(harness!, sessionId)[0]?.update).toMatchObject({
availableCommands: [{ name: 'raced' }],
})
})
})
it('executes a known single-text command directly and never sends it to the model', async () => {
harness = await makeBridgeHarness({ storageDir })
const seen = vi.fn(() => ({ kind: 'success' as const, text: 'DIRECT RESULT' }))
harness.ctx.commands.register({ name: 'direct', description: 'Run directly', handler: seen })
harness.ctx.commands.register({
name: 'silent', description: 'Return no text', handler: () => ({ kind: 'success' }),
})
harness.ctx.commands.register({
name: 'empty', description: 'Return empty text', handler: () => ({ kind: 'success', text: '' }),
})
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const response = await harness.client.prompt({
sessionId,
prompt: [{ type: 'text', text: '/direct raw args ' }],
})
expect(response.stopReason).toBe('end_turn')
expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' raw args ' }))
expect(messageText(harness, sessionId)).toContain('DIRECT RESULT')
const updatesAfterText = harness.sessionUpdates.length
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/silent' }] })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/empty' }] })
expect(harness.sessionUpdates).toHaveLength(updatesAfterText)
expect(harness.adapter.requests).toHaveLength(0)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
it('renders expected command errors and rejects unknown slash commands without model fallback', async () => {
harness = await makeBridgeHarness({ storageDir })
harness.ctx.commands.register({
name: 'denied',
description: 'Deny directly',
handler: () => ({ kind: 'error', text: 'not allowed now' }),
})
harness.ctx.commands.register({
name: 'throws',
description: 'Throw an ordinary error',
handler: () => { throw new Error('handler exploded') },
})
harness.ctx.commands.register({
name: 'hostile',
description: 'Throw a hostile value',
handler: () => {
throw { toString(): string { throw new Error('coercion exploded') } }
},
})
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/denied' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/missing input' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/throws' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/hostile' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
expect(messageText(harness, sessionId)).toContain('Error: not allowed now')
expect(messageText(harness, sessionId)).toContain('Error: unknown command: /missing input')
expect(messageText(harness, sessionId)).toContain('Error: command failed: Error: handler exploded')
expect(messageText(harness, sessionId)).toContain('Error: command failed: <unrenderable thrown value>')
expect(harness.adapter.requests).toHaveLength(0)
})
it('flattens supported command prompt blocks without invoking the model', async () => {
harness = await makeBridgeHarness({ storageDir })
const command = vi.fn(() => ({ kind: 'success' as const, text: 'combined' }))
harness.ctx.commands.register({ name: 'direct', description: 'Direct', handler: command })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.prompt({
sessionId,
prompt: [
{ type: 'text', text: '/direct' },
{ type: 'text', text: ' extra' },
{ type: 'resource_link', name: 'input', uri: 'file:///workspace/input.txt' },
],
})).resolves.toEqual({ stopReason: 'end_turn' })
expect(command).toHaveBeenCalledWith(expect.objectContaining({
rawInput: ' extra\n[resource_link name="input" uri="file:///workspace/input.txt"]\n',
}))
expect(messageText(harness, sessionId)).toContain('combined')
expect(harness.adapter.requests).toHaveLength(0)
})
it('maps session cancellation to the in-flight command signal and isolates other sessions', async () => {
harness = await makeBridgeHarness({ storageDir })
let started!: () => void
const ready = new Promise<void>((resolve) => { started = resolve })
harness.ctx.commands.register({
name: 'wait',
description: 'Wait for cancellation',
handler: ({ signal }) => {
started()
return new Promise((resolve) => {
signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late abort result' }) }, { once: true })
})
},
})
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const waiting = harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/wait' }] })
await ready
await expect(harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/wait' }] }))
.rejects.toThrow(/already in flight/)
await harness.client.cancel({ sessionId: a.sessionId })
await expect(waiting).resolves.toEqual({ stopReason: 'cancelled' })
await expect(harness.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: '/missing' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
expect(messageText(harness, a.sessionId)).not.toContain('late abort result')
})
it('aborts an in-flight command when the ACP bridge is disposed', async () => {
harness = await makeBridgeHarness({ storageDir })
let started!: () => void
const ready = new Promise<void>((resolve) => { started = resolve })
let commandSignal: AbortSignal | undefined
harness.ctx.commands.register({
name: 'wait-dispose',
description: 'Wait for bridge disposal',
handler: ({ signal }) => {
commandSignal = signal
started()
return new Promise<never>(() => {})
},
})
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const waiting = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/wait-dispose' }] })
await ready
await harness.acpFiber.dispose()
expect(commandSignal?.aborted).toBe(true)
await expect(waiting).resolves.toEqual({ stopReason: 'cancelled' })
})
it('resolves scoped command catalogs and execution independently per session', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agentA = harness.ctx.agents.get(SessionId(a.sessionId))
if (agentA === undefined) throw new Error('session A has no agent')
await agentA.ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({
name: 'private', description: 'Only session A',
handler: () => ({ kind: 'success', text: 'A ONLY' }),
})
})
await vi.waitFor(() => {
expect(commandUpdates(harness!, a.sessionId).at(-1)?.update).toMatchObject({ availableCommands: [{ name: 'private' }] })
})
expect(commandUpdates(harness, b.sessionId).at(-1)?.update).toMatchObject({ availableCommands: [] })
await harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/private' }] })
await harness.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: '/private' }] })
expect(messageText(harness, a.sessionId)).toContain('A ONLY')
expect(messageText(harness, b.sessionId)).toContain('unknown command')
})
})
+4 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -24,6 +24,9 @@ describe('acp bridge — demux & config edges', () => {
harness = await makeBridgeHarness({ storageDir, script: [textResponse('foreign')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await vi.waitFor(() => {
expect(harness!.updates.some(update => update.sessionUpdate === 'available_commands_update')).toBe(true)
})
const before = harness.updates.length
const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } })
+2
View File
@@ -9,6 +9,7 @@ import { CallId, type GenerateOptions, type LlmModelInfo, type LlmProviderInfo,
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import CommandService from '@deepseek-ai/dsh-commands'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
@@ -210,6 +211,7 @@ export async function makeBridgeHarness(options: {
await mountAgentLoopTestDependencies(ctx, {
systemPrompt: { persona: options.persona ?? '' },
})
await ctx.plugin(CommandService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
await ctx.plugin(UserInteractionService)
+3
View File
@@ -32,6 +32,9 @@
{
"path": "../../core/tools"
},
{
"path": "../commands"
},
{
"path": "../user-interaction"
},
+39
View File
@@ -0,0 +1,39 @@
# @deepseek-ai/dsh-commands
Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plugin command registration Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) owns the boundary and protocol mapping.
## Service contract
`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names.
`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
Handlers return `success` or `error` plus optional UI text. Results are rendered directly by the adapter and never enter model history. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it.
## Composition
The terminal and ACP app bundles mount this service with their consuming front door; the UI-less agent spine does not. Custom compositions that use `dsh-tui`, `dsh-acp`, or a command producer mount `@deepseek-ai/dsh-commands` explicitly.
## Model Experience
### Direct human commands
#### What the model sees
Nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt.
#### Token effect
Command discovery, execution, and UI output add no model tokens. A command plugin may separately mutate a model-visible domain through that domain's durable APIs.
#### KV Cache effect
Registry metadata, command input, and direct output never enter a model request and do not affect its cache. A mutated domain owns any later cache effect.
## Known Limitations and Deferred Work
- **Only unstructured text input** — the descriptor intentionally matches ACP's current unstructured command input; forms, completion schemas, and typed arguments remain command-owned parsing concerns.
- **No persisted command output** — adapters display results live, but the generic registry does not add them to the session log or reconstruct them after reconnect.
- **Cooperative side-effect cancellation** — dispatch stops awaiting on abort; handlers must honor the signal to stop work that has already escaped into external systems.
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@deepseek-ai/dsh-commands",
"description": "Plugin-owned human command registry for DeepSeek Harness UI surfaces",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
+321
View File
@@ -0,0 +1,321 @@
/**
* Plugin-owned human-command registry shared by interactive UI adapters.
* @module @deepseek-ai/dsh-commands
*/
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
export const name = 'commands'
const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
/** Immutable command input metadata compatible with ACP unstructured input. */
export interface CommandInputDescriptor {
/** Placeholder shown before the user supplies free-form input. */
readonly hint: string
}
/** Invocation passed to one registered command handler. */
export interface CommandInvocation {
/** Exact agent whose human-facing surface received the command. */
readonly agent: Agent
/** Exact text following the registered command name, including separator whitespace. */
readonly rawInput: string
/** Cancellation signal owned by the dispatching UI request. */
readonly signal: AbortSignal
}
/** Expected command outcome rendered directly by the dispatching UI. */
export type CommandResult =
| { readonly kind: 'success'; readonly text?: string }
| { readonly kind: 'error'; readonly text: string }
/** Plugin-owned command registration. */
export interface CommandDefinition {
/** Lowercase command name without the leading slash. */
readonly name: string
/** Human-readable summary used in discovery UI. */
readonly description: string
/** Optional free-form input hint advertised to capable clients. */
readonly input?: CommandInputDescriptor
/** Execute against the receiving agent without sending the command to the model. */
readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
}
/** Handler-free immutable command view returned to UI adapters. */
export interface CommandDescriptor {
/** Lowercase command name without the leading slash. */
readonly name: string
/** Human-readable summary used in discovery UI. */
readonly description: string
/** Optional free-form input hint advertised to capable clients. */
readonly input?: CommandInputDescriptor
}
/** Syntactically valid slash command before registry resolution. */
export interface ParsedCommand {
/** Lowercase command name without the leading slash. */
readonly name: string
/** Exact text following the command name. */
readonly rawInput: string
}
interface RegisteredCommand {
readonly definition: CommandDefinition
readonly descriptor: CommandDescriptor
}
declare module 'cordis' {
interface Context {
commands: CommandService
}
interface Events {
/**
* A command was registered or unregistered. This is an unfiltered registry
* notification because a global or scoped change may affect any UI view.
* Observer failures are contained and cannot veto the registry mutation.
* @mode emit
*/
'commands/change'(): void
}
}
/**
* Parse an exact slash command without normalizing its trailing input.
*
* @param line - Complete candidate command line.
* @returns The parsed command, or `undefined` when the line is not a command.
*/
export function parseCommand(line: string): ParsedCommand | undefined {
const match = /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u.exec(line)
if (match === null) return undefined
const name = match[1]
/* v8 ignore next -- the first capture is required whenever the regular expression matches */
if (name === undefined) return undefined
return Object.freeze({ name, rawInput: line.slice(match[0].length) })
}
/** Convert arbitrary abort reasons to one stable rejected Error. */
function abortError(signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason
return new Error(typeof signal.reason === 'string' ? signal.reason : 'command aborted')
}
/** Render arbitrary thrown values without trusting their string coercion. */
function renderThrown(value: unknown): string {
try {
return String(value)
} catch {
return '<unrenderable thrown value>'
}
}
/** Stop awaiting an uncooperative handler once its owning UI request aborts. */
function withAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(abortError(signal))
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
signal.removeEventListener('abort', onAbort)
reject(abortError(signal))
}
signal.addEventListener('abort', onAbort, { once: true })
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(error instanceof Error
? error
: new Error(`command handler rejected with a non-Error value: ${renderThrown(error)}`, { cause: error }))
},
)
})
}
/** Reject invalid command metadata before it can reach a UI protocol. */
function normalizeDefinition(definition: CommandDefinition): RegisteredCommand {
if (!COMMAND_NAME.test(definition.name)) {
throw new TypeError(`command name "${definition.name}" must match ${String(COMMAND_NAME)}`)
}
if (typeof definition.description !== 'string') {
throw new TypeError(`command "${definition.name}" description must be a string`)
}
if (definition.description.trim().length === 0) {
throw new TypeError(`command "${definition.name}" description must not be empty`)
}
if (typeof definition.handler !== 'function') {
throw new TypeError(`command "${definition.name}" handler must be a function`)
}
const rawInput: unknown = definition.input
let input: CommandInputDescriptor | undefined
if (rawInput !== undefined) {
if (typeof rawInput !== 'object' || rawInput === null || !('hint' in rawInput)
|| typeof rawInput.hint !== 'string') {
throw new TypeError(`command "${definition.name}" input hint must be a string`)
}
if (rawInput.hint.trim().length === 0) {
throw new TypeError(`command "${definition.name}" input hint must not be empty`)
}
input = Object.freeze({ hint: rawInput.hint })
}
const normalized = Object.freeze({
name: definition.name,
description: definition.description,
...input === undefined ? {} : { input },
handler: definition.handler,
})
const descriptor = Object.freeze({
name: normalized.name,
description: normalized.description,
...normalized.input === undefined ? {} : { input: normalized.input },
})
return { definition: normalized, descriptor }
}
/** Validate and detach an untrusted handler result at the registry boundary. */
function normalizeResult(command: string, value: unknown): CommandResult {
if (typeof value !== 'object' || value === null || !('kind' in value)) {
throw new TypeError(`command "${command}" handler must return a CommandResult`)
}
const result = value as { kind?: unknown; text?: unknown }
if (result.kind === 'success') {
if (result.text !== undefined && typeof result.text !== 'string') {
throw new TypeError(`command "${command}" success text must be a string when supplied`)
}
return Object.freeze(result.text === undefined ? { kind: 'success' } : { kind: 'success', text: result.text })
}
if (result.kind === 'error') {
if (typeof result.text !== 'string' || result.text.trim().length === 0) {
throw new TypeError(`command "${command}" error text must be a non-empty string`)
}
return Object.freeze({ kind: 'error', text: result.text })
}
throw new TypeError(`command "${command}" returned unknown result kind "${String(result.kind)}"`)
}
/**
* Human-command registry. Plain-context definitions are global; definitions
* registered through a command-injected child of an agent context shadow
* globals for that agent.
*/
export class CommandService extends Service {
private readonly global = new Map<string, RegisteredCommand>()
private readonly scoped = new Map<ScopeKey, Map<string, RegisteredCommand>>()
constructor(ctx: Context) {
super(ctx, 'commands')
}
/**
* Register a global or calling-agent-scoped command.
* @param definition - discovery metadata and direct UI handler.
* @returns the exact effect disposer that unregisters this definition.
*/
register(definition: CommandDefinition): () => void {
const scope = scopeOf(this.ctx)
const registered = normalizeDefinition(definition)
const dispose = this.ctx.effect(function* (this: CommandService) {
const layer = scope === undefined ? this.global : this.layerFor(scope)
if (layer.has(registered.definition.name)) {
throw new Error(scope === undefined
? `command "${registered.definition.name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
: `command "${registered.definition.name}" is already registered in this scope`)
}
layer.set(registered.definition.name, registered)
yield () => {
layer.delete(registered.definition.name)
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
this.notifyChange()
}
this.notifyChange()
}.bind(this), 'commands.register()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order
return dispose
}
/**
* List the effective immutable command descriptors for one agent.
* @param agent - exact receiving agent and scoped-layer key.
* @returns name-sorted descriptors after scoped shadowing.
*/
list(agent: Agent): readonly CommandDescriptor[] {
return Object.freeze([...this.view(agent).values()]
.map(command => command.descriptor)
// Names are unique in the effective view, so equality is impossible.
.sort((left, right) => left.name < right.name ? -1 : 1))
}
/**
* Resolve one effective command definition.
* @param agent - exact receiving agent and scoped-layer key.
* @param name - command name without a slash.
* @returns the scoped shadow or global definition.
*/
find(agent: Agent, name: string): CommandDefinition | undefined {
return this.view(agent).get(name)?.definition
}
/**
* Parse and execute a known command without sending it to the model.
* @param agent - exact receiving agent.
* @param line - complete slash-command line.
* @param signal - cancellation signal owned by the UI request.
* @returns a detached result, or `undefined` when syntax or name does not resolve.
*/
async execute(
agent: Agent,
line: string,
signal: AbortSignal,
): Promise<CommandResult | undefined> {
const parsed = parseCommand(line)
if (parsed === undefined) return undefined
const command = this.view(agent).get(parsed.name)
if (command === undefined) return undefined
if (signal.aborted) throw abortError(signal)
const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal })
const output = command.definition.handler(invocation)
return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
}
/** Resolve global definitions followed by exact scoped shadows. */
private view(agent: Agent): Map<string, RegisteredCommand> {
const visible = new Map(this.global)
for (const [name, command] of this.scoped.get(agent) ?? []) visible.set(name, command)
return visible
}
/** Create the registration layer for one agent scope on demand. */
private layerFor(scope: ScopeKey): Map<string, RegisteredCommand> {
let layer = this.scoped.get(scope)
if (layer === undefined) {
layer = new Map()
this.scoped.set(scope, layer)
}
return layer
}
/** Notify every registry observer without making UI refresh load-bearing. */
private notifyChange(): void {
// Cordis emit uses Array.map: one synchronous throw starves later listeners,
// and returned promises are discarded. Registry notifications are
// non-vetoing, so contain each callback independently.
for (const callback of this.ctx.events.dispatch('emit', ['commands/change'])) {
try {
const returned: unknown = callback()
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`commands/change listener rejected: ${renderThrown(error)}`)
})
} catch (error: unknown) {
this.ctx.logger.warn(`commands/change listener threw: ${renderThrown(error)}`)
}
}
}
}
export default CommandService
+294
View File
@@ -0,0 +1,294 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import CommandService, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands'
function command(name: string, text = `ran:${name}`): CommandDefinition {
return {
name,
description: `command ${name}`,
handler: () => ({ kind: 'success', text }),
}
}
async function mount(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(CommandService)
return ctx
}
/** Mint a scope whose key is sufficient for registry lookup and invocation. */
async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; agent: Agent }> {
const agent = { id: name as SessionId } as Agent
let scope!: Scope
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['commands'] }))
return { scope, agent }
}
describe('parseCommand()', () => {
it.each([
['/goal', { name: 'goal', rawInput: '' }],
['/goal create the thing', { name: 'goal', rawInput: ' create the thing' }],
['/goal\ncreate the thing', { name: 'goal', rawInput: '\ncreate the thing' }],
['/goal_name-2\t x ', { name: 'goal_name-2', rawInput: '\t x ' }],
] as const)('parses %j without normalizing trailing input', (line, expected) => {
expect(parseCommand(line)).toEqual(expected)
})
it.each(['goal', ' /goal', '/', '/Goal', '/goal/path', '/goal🔥'])('rejects non-command boundary %j', (line) => {
expect(parseCommand(line)).toBeUndefined()
})
})
describe('CommandService', () => {
it('lists immutable global descriptors with input metadata', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
const definition: CommandDefinition = {
name: 'inspect',
description: 'Inspect state',
input: { hint: '<target>' },
handler: () => ({ kind: 'success' }),
}
ctx.commands.register(definition)
const listed = ctx.commands.list(agent)
expect(listed).toEqual([{
name: 'inspect',
description: 'Inspect state',
input: { hint: '<target>' },
}])
expect(Object.isFrozen(listed)).toBe(true)
expect(Object.isFrozen(listed[0])).toBe(true)
expect(Object.isFrozen(listed[0]?.input)).toBe(true)
expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect' })
expect(ctx.commands.find(agent, 'missing')).toBeUndefined()
})
it('sorts distinct effective command names', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
ctx.commands.register(command('zeta'))
ctx.commands.register(command('alpha'))
ctx.commands.register(command('middle'))
expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['alpha', 'middle', 'zeta'])
})
it('uses agent-scoped shadows and removes them with their scope', async () => {
const ctx = await mount()
const { scope, agent } = await mintAgentScope(ctx, 'a')
const other = { id: 'other' as SessionId } as Agent
ctx.commands.register(command('shared', 'global'))
scope.ctx.commands.register(command('shared', 'scoped'))
expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared'])
expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined()
expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared'])
expect(await ctx.commands.execute(agent, '/shared', new AbortController().signal))
.toEqual({ kind: 'success', text: 'scoped' })
await scope.dispose()
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
})
it('rejects duplicates within one layer while allowing a scoped shadow', async () => {
const ctx = await mount()
const { scope } = await mintAgentScope(ctx, 'a')
ctx.commands.register(command('same'))
expect(() => ctx.commands.register(command('same'))).toThrow(/agent\.ctx/)
scope.ctx.commands.register(command('same'))
expect(() => scope.ctx.commands.register(command('same'))).toThrow(/already registered in this scope/)
})
it('notifies on registration and disposal while containing broken observers', async () => {
const ctx = await mount()
const changed = vi.fn()
ctx.on('commands/change', changed)
const dispose = ctx.commands.register(command('live'))
dispose()
dispose()
expect(changed).toHaveBeenCalledTimes(2)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
ctx.on('commands/change', () => { throw new Error('observer threw') })
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment
ctx.on('commands/change', () => Promise.reject(new Error('observer rejected')))
const afterFailures = vi.fn()
ctx.on('commands/change', afterFailures)
const removeContained = ctx.commands.register(command('contained'))
const { agent } = await mintAgentScope(ctx, 'a')
expect(ctx.commands.find(agent, 'contained')).toBeDefined()
expect(afterFailures).toHaveBeenCalledTimes(1)
await vi.waitFor(() => {
expect(warn).toHaveBeenCalledWith('commands/change listener threw: Error: observer threw')
expect(warn).toHaveBeenCalledWith('commands/change listener rejected: Error: observer rejected')
})
removeContained()
expect(ctx.commands.find(agent, 'contained')).toBeUndefined()
expect(afterFailures).toHaveBeenCalledTimes(2)
})
it('rejects non-string descriptions and input hints with boundary diagnostics', async () => {
const ctx = await mount()
expect(() => ctx.commands.register({
...command('description-type'),
description: undefined,
} as unknown as CommandDefinition)).toThrow('command "description-type" description must be a string')
expect(() => ctx.commands.register({
...command('hint-type'),
input: { hint: 42 },
} as unknown as CommandDefinition)).toThrow('command "hint-type" input hint must be a string')
expect(() => ctx.commands.register({
...command('input-type'),
input: null,
} as unknown as CommandDefinition)).toThrow('command "input-type" input hint must be a string')
})
it('passes exact invocation context and detaches valid handler results', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
const seen = vi.fn(() => ({ kind: 'success' as const, text: 'ok' }))
ctx.commands.register({ name: 'run', description: 'Run it', handler: seen })
const controller = new AbortController()
const result = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
expect(result).toEqual({ kind: 'success', text: 'ok' })
expect(Object.isFrozen(result)).toBe(true)
expect(seen).toHaveBeenCalledWith(expect.objectContaining({
agent,
rawInput: ' untouched ',
signal: controller.signal,
}))
await expect(ctx.commands.execute(agent, 'run', controller.signal)).resolves.toBeUndefined()
await expect(ctx.commands.execute(agent, '/missing', controller.signal)).resolves.toBeUndefined()
})
it('stops awaiting an aborted handler and handles an already-aborted signal', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
let release!: (result: { kind: 'success'; text: string }) => void
ctx.commands.register({
name: 'wait',
description: 'Wait',
handler: () => new Promise((resolve) => { release = resolve }),
})
const running = new AbortController()
const promise = ctx.commands.execute(agent, '/wait', running.signal)
running.abort('operator cancelled command')
await expect(promise).rejects.toThrow('operator cancelled command')
release({ kind: 'success', text: 'late' })
const already = new AbortController()
already.abort(new Error('already gone'))
await expect(ctx.commands.execute(agent, '/wait', already.signal)).rejects.toThrow('already gone')
const defaultReason = new AbortController()
defaultReason.abort({ source: 'test' })
await expect(ctx.commands.execute(agent, '/wait', defaultReason.signal)).rejects.toThrow('command aborted')
})
it('propagates an asynchronously rejected handler', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
ctx.commands.register({
name: 'reject',
description: 'Reject',
handler: () => Promise.reject(new Error('handler rejected')),
})
await expect(ctx.commands.execute(agent, '/reject', new AbortController().signal))
.rejects.toThrow('handler rejected')
ctx.commands.register({
name: 'reject-value',
description: 'Reject a non-Error value',
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise untyped plugin normalization
handler: () => Promise.reject('not an Error'),
})
await expect(ctx.commands.execute(agent, '/reject-value', new AbortController().signal))
.rejects.toThrow('command handler rejected with a non-Error value: not an Error')
const hostile = { toString(): string { throw new Error('cannot render') } }
ctx.commands.register({
name: 'reject-hostile',
description: 'Reject an unrenderable value',
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise hostile plugin normalization
handler: () => Promise.reject(hostile),
})
await expect(ctx.commands.execute(agent, '/reject-hostile', new AbortController().signal))
.rejects.toMatchObject({
message: 'command handler rejected with a non-Error value: <unrenderable thrown value>',
cause: hostile,
})
})
it('observes an abort triggered synchronously inside the handler', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
const controller = new AbortController()
ctx.commands.register({
name: 'self-abort',
description: 'Abort before returning',
handler: () => {
controller.abort('aborted in handler')
return { kind: 'success' }
},
})
await expect(ctx.commands.execute(agent, '/self-abort', controller.signal))
.rejects.toThrow('aborted in handler')
})
it('returns a detached expected-error result', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
ctx.commands.register({
name: 'denied',
description: 'Denied',
handler: () => ({ kind: 'error', text: 'not now' }),
})
const result = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
expect(result).toEqual({ kind: 'error', text: 'not now' })
expect(Object.isFrozen(result)).toBe(true)
ctx.commands.register({
name: 'silent',
description: 'No output',
handler: () => ({ kind: 'success' }),
})
const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal)
expect(silent).toEqual({ kind: 'success' })
expect(Object.isFrozen(silent)).toBe(true)
})
it.each([
[{ ...command('Bad') }, /command name/],
[{ ...command('empty-description'), description: ' ' }, /description/],
[{ ...command('empty-hint'), input: { hint: '' } }, /input hint/],
[{ ...command('bad-handler'), handler: undefined }, /handler/],
] as const)('rejects invalid definition %#', async (definition, expected) => {
const ctx = await mount()
expect(() => ctx.commands.register(definition as unknown as CommandDefinition)).toThrow(expected)
})
it.each([
[undefined, /CommandResult/],
[null, /CommandResult/],
[{}, /CommandResult/],
[{ kind: 'success', text: 1 }, /success text/],
[{ kind: 'error', text: '' }, /error text/],
[{ kind: 'error', text: 1 }, /error text/],
[{ kind: 'future', text: 'x' }, /unknown result kind/],
] as const)('rejects malformed handler result %j', async (output, expected) => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
ctx.commands.register({
name: 'broken',
description: 'Broken',
handler: () => output as never,
})
await expect(ctx.commands.execute(agent, '/broken', new AbortController().signal)).rejects.toThrow(expected)
})
})
+24
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": "../../core/agent"
},
{
"path": "../../core/scope"
}
]
}
+4 -4
View File
@@ -6,7 +6,7 @@ The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feat
Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification.
This package owns interactive terminal presentation and input only. It injects `agents`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Surface replacement events rebuild the transcript so compacted history does not reappear.
@@ -14,7 +14,7 @@ An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide the same actions without key chords.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
## Config
@@ -41,7 +41,7 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t
maxToolOutputLines: 12
```
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
## Color
@@ -53,7 +53,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic
#### What the model sees
Each non-empty editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only.
Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only; command results remain terminal notices.
#### Token effect
+2
View File
@@ -24,6 +24,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -39,6 +40,7 @@
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
+99 -42
View File
@@ -35,6 +35,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-loop'
import type {} from '@deepseek-ai/dsh-commands'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
@@ -55,7 +56,7 @@ import {
} from '@deepseek-ai/dsh-user-interaction'
export const name = 'ui-tui'
export const inject = ['agents', 'userInteraction', 'tools']
export const inject = ['agents', 'commands', 'userInteraction', 'tools']
/** Presentation settings for the pi-tui terminal mode. */
export interface TuiConfig {
@@ -865,6 +866,7 @@ export function createTuiChat(
const allToolCards = new Set<ToolCardComponent>()
const liveErrors = new Set<string>()
const questionQueue: PendingQuestion[] = []
const commandControllers = new Set<AbortController>()
let activeQuestion: PendingQuestion | undefined
const welcome = config.welcome ?? 'ready.'
@@ -1146,6 +1148,8 @@ export function createTuiChat(
shuttingDown ??= (async () => {
disposed = true
clearStatus()
for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
commandControllers.clear()
if (activeQuestion !== undefined) {
const pending = activeQuestion
activeQuestion = undefined
@@ -1170,16 +1174,6 @@ export function createTuiChat(
void shutdown(true)
}
editor.setAutocompleteProvider(new CombinedAutocompleteProvider([
{ name: 'help', description: 'Show keyboard shortcuts and commands' },
{ name: 'clear', description: 'Clear the transcript view (session history is unchanged)' },
{ name: 'cancel', description: 'Cancel the active turn' },
{ name: 'reasoning', description: 'Toggle reasoning blocks' },
{ name: 'tools', description: 'Expand or collapse all tool cards' },
{ name: 'redraw', description: 'Invalidate components and redraw the terminal' },
{ name: 'exit', description: 'Exit after the active turn reaches idle' },
], agent.session.header.cwd ?? process.cwd()))
const toggleTools = (): void => {
toolsExpanded = !toolsExpanded
for (const card of allToolCards) card.setExpanded(toolsExpanded)
@@ -1199,52 +1193,107 @@ export function createTuiChat(
}
const showHelp = (): void => {
const commandLines = ctx.commands.list(agent).map((command) => {
const input = command.input === undefined ? '' : ` ${command.input.hint}`
return `/${command.name}${input}${command.description}`
})
chat.addChild(new Spacer(1))
chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0))
chat.addChild(new Text([
'Enter send • Shift/Alt+Enter newline • Up/Down prompt history',
'Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning',
'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit',
'/help /clear /cancel /reasoning /tools /redraw /exit',
'',
...commandLines,
].map(line => palette.muted(line)).join('\n'), 1, 0))
requestRender()
}
const refreshCommandAutocomplete = (): void => {
editor.setAutocompleteProvider(new CombinedAutocompleteProvider(
ctx.commands.list(agent).map(command => ({
name: command.name,
description: command.description,
})),
agent.session.header.cwd ?? process.cwd(),
))
}
const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete)
refreshCommandAutocomplete()
// The agent scope is minted by agent-loop and intentionally inherits only
// that core plugin's dependencies. A child command producer declares its own
// UI-service dependency while retaining the parent agent scope and lifetime.
const commandFiber = agent.ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({
name: 'help',
description: 'Show keyboard shortcuts and commands',
handler: () => { showHelp(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'clear',
description: 'Clear the transcript view (session history is unchanged)',
handler: () => { chat.clear(); requestRender(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'cancel',
description: 'Cancel the active turn',
handler: () => {
if (agent.status !== 'running') return { kind: 'error', text: 'The agent is already idle.' }
agent.cancel('cancelled from terminal')
return { kind: 'success', text: 'Cancellation requested.' }
},
})
commandCtx.commands.register({
name: 'reasoning',
description: 'Toggle reasoning blocks',
handler: () => { toggleReasoning(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'tools',
description: 'Expand or collapse all tool cards',
handler: () => { toggleTools(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'redraw',
description: 'Invalidate components and redraw the terminal',
handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'exit',
description: 'Exit after the active turn reaches idle',
handler: () => { requestExit(); return { kind: 'success' } },
})
})
const runCommand = (text: string): void => {
const controller = new AbortController()
commandControllers.add(controller)
void ctx.commands.execute(agent, text, controller.signal).then(
(result) => {
if (disposed) return
if (result === undefined) {
appendNotice(`Unknown command: ${text}`, 'warning')
} else if (result.text !== undefined && result.text !== '') {
appendNotice(result.text, result.kind === 'error' ? 'error' : 'info')
}
},
(error: unknown) => {
if (!disposed) {
appendNotice(`Command failed: ${errorChain(error)}`, 'error')
}
},
).finally(() => { commandControllers.delete(controller) })
}
editor.onSubmit = (value: string) => {
const text = value.trim()
if (text === '') return
editor.addToHistory(text)
editor.setText('')
switch (text) {
case '/help':
showHelp()
return
case '/clear':
chat.clear()
requestRender()
return
case '/cancel':
if (agent.status === 'running') agent.cancel('cancelled from terminal')
else appendNotice('The agent is already idle.')
return
case '/reasoning':
toggleReasoning()
return
case '/tools':
toggleTools()
return
case '/redraw':
ui.invalidate()
ui.requestRender(true)
return
case '/exit':
requestExit()
return
default:
if (text.startsWith('/')) {
appendNotice(`Unknown command: ${text}`, 'warning')
return
}
if (value.startsWith('/')) {
runCommand(value)
return
}
if (agent.status === 'disposed') {
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
@@ -1321,6 +1370,7 @@ export function createTuiChat(
const detachListeners = (): void => {
removeInputListener()
disposeCommandChanges()
disposeSessionEvents()
disposeStatus()
disposeError()
@@ -1334,6 +1384,12 @@ export function createTuiChat(
} catch (error: unknown) {
disposed = true
detachListeners()
void commandFiber.dispose().catch(
/* v8 ignore next 2 -- command registration cleanup is non-throwing; this guards a future disposer regression */
(cleanupError: unknown) => {
ctx.logger.warn(`ui-tui: command cleanup after startup failure failed: ${errorChain(cleanupError)}`)
},
)
clearStatus()
disposeUserInteraction()
ui.stop()
@@ -1344,6 +1400,7 @@ export function createTuiChat(
async dispose(): Promise<void> {
detachListeners()
await shutdown(false)
await commandFiber.dispose()
},
}
}
+2
View File
@@ -1,6 +1,7 @@
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
@@ -48,6 +49,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
if (options.configureContext === undefined) {
const tools = options.tools ?? {}
+1 -1
View File
@@ -12,7 +12,7 @@ describe('dsh-tui plugin export shape', () => {
const unwrapped = loader.unwrapExports(tui) as Record<string, unknown>
expect(unwrapped).toBe(tui)
expect(unwrapped.name).toBe('ui-tui')
expect(unwrapped.inject).toEqual(['agents', 'userInteraction', 'tools'])
expect(unwrapped.inject).toEqual(['agents', 'commands', 'userInteraction', 'tools'])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
})
@@ -1,7 +1,7 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=1 progress=inactive
title "DSH snapshot"
cursor visible column=0 viewportRow=22 bufferRow=22
cursor visible column=0 viewportRow=29 bufferRow=29
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
@@ -29,24 +29,37 @@ buffer
style 1-75 fg=bright-black
9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
10| " /help /clear /cancel /reasoning /tools /redraw /exit "
style 1-52 fg=bright-black
11| <blank>
12| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
13| <blank>
14| " provider stream failed after partial output "
10| " "
11| " /cancel — Cancel the active turn "
style 1-32 fg=bright-black
12| " /clear — Clear the transcript view (session history is unchanged) "
style 1-65 fg=bright-black
13| " /exit — Exit after the active turn reaches idle "
style 1-47 fg=bright-black
14| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
15| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
16| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
17| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
18| <blank>
19| " provider stream failed after partial output "
style 1-43 fg=red
15| <blank>
16| " The previous process ended during this turn. "
20| <blank>
21| " The previous process ended during this turn. "
style 1-44 fg=yellow
17| "────────────────────────────────────────────────────────────────────────────────────────────"
22| <blank>
23| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
24| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
18| " "
25| " "
style 1-1 inverse
19| "────────────────────────────────────────────────────────────────────────────────────────────"
26| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
style 0-24 dim
style 59-91 dim
21-31| <blank>
28-31| <blank>
@@ -1,7 +1,7 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=18 bufferRow=18
cursor hidden column=1 viewportRow=25 bufferRow=25
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
@@ -29,24 +29,37 @@ buffer
style 1-75 fg=bright-black
9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
10| " /help /clear /cancel /reasoning /tools /redraw /exit "
style 1-52 fg=bright-black
11| <blank>
12| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
13| <blank>
14| " provider stream failed after partial output "
10| " "
11| " /cancel — Cancel the active turn "
style 1-32 fg=bright-black
12| " /clear — Clear the transcript view (session history is unchanged) "
style 1-65 fg=bright-black
13| " /exit — Exit after the active turn reaches idle "
style 1-47 fg=bright-black
14| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
15| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
16| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
17| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
18| <blank>
19| " provider stream failed after partial output "
style 1-43 fg=red
15| <blank>
16| " The previous process ended during this turn. "
20| <blank>
21| " The previous process ended during this turn. "
style 1-44 fg=yellow
17| "────────────────────────────────────────────────────────────────────────────────────────────"
22| <blank>
23| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
24| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
18| " "
25| " "
style 1-1 inverse
19| "────────────────────────────────────────────────────────────────────────────────────────────"
26| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
style 0-24 dim
style 59-91 dim
21-31| <blank>
28-31| <blank>
+111
View File
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -516,6 +517,108 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(disposedAgent)
})
it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => {
const result = await setup()
const handler = vi.fn(({ rawInput }: CommandInvocation) => ({
kind: 'success' as const,
text: `PLUGIN:${rawInput}`,
}))
result.ctx.commands.register({
name: 'plugin-check',
description: 'Run a plugin command',
input: { hint: '<value>' },
handler,
})
result.ctx.commands.register({
name: 'plugin-fail',
description: 'Fail a plugin command',
handler: () => { throw new Error('plugin command exploded') },
})
result.terminal.send('/plugin-check value ')
result.terminal.send('\r')
await tick()
expect(handler).toHaveBeenCalledTimes(1)
const invocation = handler.mock.calls[0]?.[0]
expect(invocation?.agent).toBe(result.agent)
// pi-tui's Editor owns terminal-line normalization and removes trailing
// spaces before onSubmit; the registry preserves the adapter-delivered line.
expect(invocation?.rawInput).toBe(' value')
expect(result.terminal.output).toContain('PLUGIN: value')
result.terminal.send('/plugin-fail')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Command failed: plugin command exploded')
result.terminal.send('/help')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('/plugin-check <value> — Run a plugin command')
expect(result.ctx.commands.list(result.agent).map(command => command.name)).toContain('help')
await result.controller.dispose()
expect(result.ctx.commands.list(result.agent).map(command => command.name)).toEqual([
'plugin-check',
'plugin-fail',
])
await result.ctx.fiber.dispose()
})
it('aborts an in-flight plugin command during TUI disposal', async () => {
const result = await setup()
let started!: () => void
const ready = new Promise<void>((resolve) => { started = resolve })
let commandSignal: AbortSignal | undefined
result.ctx.commands.register({
name: 'wait-plugin',
description: 'Wait until disposal',
handler: ({ signal }) => {
commandSignal = signal
started()
return new Promise((resolve) => {
signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late result' }) }, { once: true })
})
},
})
result.terminal.send('/wait-plugin')
result.terminal.send('\r')
await ready
await result.controller.dispose()
expect(commandSignal?.aborted).toBe(true)
expect(result.terminal.output).not.toContain('late result')
await result.ctx.fiber.dispose()
})
it('suppresses a successful plugin result that settles as TUI disposal starts', async () => {
const result = await setup()
let started!: () => void
const ready = new Promise<void>((resolve) => { started = resolve })
let resolveCommand!: (result: { kind: 'success'; text: string }) => void
result.ctx.commands.register({
name: 'late-success',
description: 'Resolve while the TUI closes',
handler: () => new Promise((resolve) => {
resolveCommand = resolve
started()
}),
})
result.terminal.send('/late-success')
result.terminal.send('\r')
await ready
resolveCommand({ kind: 'success', text: 'must not render after disposal' })
// Let the command boundary accept the result before disposal, but leave the
// TUI continuation queued so the success-side disposal guard owns the race.
await Promise.resolve()
await result.controller.dispose()
await tick()
expect(result.terminal.output).not.toContain('must not render after disposal')
await result.ctx.fiber.dispose()
})
it('cancels before /exit while running and handles agent errors/disposal', async () => {
const result = await setup({ status: 'running' })
result.terminal.send('/exit')
@@ -899,6 +1002,7 @@ describe('terminal mounting', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const session = ctx.sessions.create(SessionId('main'))
@@ -917,6 +1021,7 @@ describe('terminal mounting', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const terminal = new FakeTerminal()
@@ -945,6 +1050,7 @@ describe('terminal mounting', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const terminal = new FakeTerminal()
@@ -972,6 +1078,7 @@ describe('terminal mounting', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const terminal = new FakeTerminal()
@@ -992,6 +1099,7 @@ describe('terminal mounting', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const session = ctx.sessions.create(SessionId('failed-start-session'))
@@ -1004,6 +1112,8 @@ describe('terminal mounting', () => {
expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() }))
.toThrow('terminal startup failed')
await tick()
expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([])
expect(terminal.stopped).toBe(1)
expect(terminal.progress).toEqual([false, true, false])
await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
@@ -1021,6 +1131,7 @@ describe('terminal mounting', () => {
it('throws when createTuiChat is called without the configured agent', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const runtime: TuiRuntime = { terminal: new FakeTerminal(), exit: vi.fn() }
+3
View File
@@ -32,6 +32,9 @@
{
"path": "../../core/tools"
},
{
"path": "../commands"
},
{
"path": "../user-interaction"
}
+2 -1
View File
@@ -7,7 +7,8 @@ The workflow seam: a model-written JavaScript orchestration script that fans out
| `workflow/` | Abstract workflow seam: service base class + run vocabulary + `workflow/*` events | `ctx.workflows` |
| `workflow-workerthread/` | `node:worker_threads` engine: one worker per run; the script's vm context lives inside the worker, `agent()` bridges to `ctx.subagents` over the message port | (provides `ctx.workflows`) |
| `tool-workflow/` | Model-facing `workflow` tool over `ctx.workflows` | (registers on `ctx.tools`) |
| `tool-ralph/` | Fixed fresh-agent Ralph policy over `ctx.workflows` and a fresh structured-output subagent provider | (registers on `ctx.tools`) |
The interface lives at `workflow/workflow/`. The engine's `agent()` hook rides the [subagent seam](../subagent/README.md) (any registered provider; the shipped examples use `spawn`), and `agent({ schema })` rides the structured-output support the in-process backends implement. The worker thread isolates the SCRIPT — the host never blocks on it, and a cancelled run's post-grace termination is real — but it is NOT a security boundary; an isolated-vm/separate-process engine (actual sandboxing) swaps in behind the same interface if that ever matters.
The proposal, decisions, and deferred work: [.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md).
The general script engine's decisions and deferred work live in the [dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md). The separate [Ralph consumer](../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md) fixes the script and fresh-provider policy rather than adding another engine or an agent-loop mode.
+91
View File
@@ -0,0 +1,91 @@
# @deepseek-ai/dsh-tool-ralph
The model-facing `ralph` tool runs a fixed foreground workflow that gives one immutable objective to a sequence of fresh child agents. It demonstrates a specialized orchestration policy as an ordinary plugin over [`ctx.workflows`](../workflow/README.md) and [`ctx.subagents`](../../subagent/subagent/README.md): no Ralph mode or fresh-agent loop is added to `agent-loop`, and the same-session [goal domain](../../goal/goal/README.md) remains independent. The [Ralph Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md) owns the policy and deferred work.
## Contract
`ralph({ objective, maxRounds? })` waits for the entire run. The deployment config's `maxRounds` is both the default and a ceiling on a call override. Every Ralph round starts one child through `subagentProvider`; that provider must exist, support structured output, and report `inheritsParentContext: false`. The configured provider is carried as `WorkflowStartRequest.subagentProvider`, so the fixed script cannot inspect or change routing and the ordinary model-written `workflow` tool gains no provider selector. The resolved round cap is also carried as `WorkflowStartRequest.maxTotalAgents`, coordinating the fixed loop with the engine's total-child backstop; the engine rejects a Ralph cap above its deployment ceiling before publishing a run.
Each child receives only the immutable objective, its current Ralph round and cap, a shared-workspace-as-authority instruction, and the previous structured handoff. The workspace is long-term memory; parent conversation and prior child sessions are not seeded. Reports have `status: continue | complete | blocked`, a non-empty summary, evidence, next steps, and blocker text. Status-specific semantics and the serialized `maxHandoffChars` ceiling are validated inside the fixed workflow and again at the consumer boundary. Invalid, missing, or oversized reports fail the workflow instead of being truncated or mistaken for cap exhaustion.
The successful terminal tool result is `complete`, `blocked`, or `budget-limited`, with the last bounded report and number of rounds started. Completion and blocker labels explicitly say that a worker reported the outcome; they are not independent certification. `maxResultChars` bounds the complete successful text including its envelope and truncation marker, without altering the validated report used as a cross-round handoff.
An ordinary child failure produces an error naming the failed round and retaining the last successful handoff when one exists. Ralph does not retry that round. Fatal provider-start, transport, worker, or workflow failures remain workflow errors and may settle before the fixed script can return a handoff. Cancellation is also an error; partial output is never success.
## Lifecycle and cancellation
The caller's agent is the parent of every fresh child, preserving cwd and lineage without copying its conversation. `exec.signal` enters the workflow engine and is also bridged to `run.cancel()` for implementation independence. The tool awaits `run.result` and calls `run.dispose()` in `finally`, so a cancelled parent step waits for the engine's bounded termination and child quiescence before returning.
## Render intent
The pending call is a `generic` card titled `ralph`; the immutable objective is its `rawInput`. The result keeps the generic card. Both presentation functions depend only on tool arguments and the settled tool envelope.
## Config
| Key | Default | Meaning |
|---|---|---|
| `subagentProvider` | `spawn` | Fresh structured-output provider used for every round. |
| `maxRounds` | `256` | Default and deployment ceiling for one Ralph run. |
| `maxHandoffChars` | `16384` | Maximum serialized characters in one round report. |
| `maxResultChars` | `16384` | Maximum characters in the complete successful parent result. |
All config values are normalized and validated when the plugin applies, including direct application outside Loader schema normalization. Provider capabilities are resolved immediately before each call because provider registration can change under plugin lifecycle and HMR.
## Model Experience
### System prompt
#### What the model sees
Every parent request in this plugin's registration scope receives the fixed routing guidance below.
##### Ralph guidance
```markdown
Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
```
#### Token effect
Small fixed guidance cost per request while the plugin is active.
#### KV Cache effect
Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section.
### Tool schema
#### What the model sees
The generated [`ralph` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ralph) exposes one required `objective` string and one optional `maxRounds` number. Provider choice, handoff size, report schema, workflow script, and orchestration behavior are deployment-owned and absent from the call surface.
#### Token effect
Small fixed schema cost on each request where the tool is visible.
#### KV Cache effect
Prefix-stable while the definition and visibility are unchanged.
### Child requests and parent result
#### What the model sees
Each child sees the standalone fixed round prompt plus the structured-output capture contract. The parent sees only the original call and one terminal result containing a worker-reported status, round count, and pretty-printed final report; intermediate child messages and reports do not enter the parent conversation. A failed ordinary child instead yields an error with its round number and, after round one, the last successful handoff.
#### Token effect
Every round pays for a fresh child context. `maxHandoffChars` bounds cross-round state and `maxResultChars` independently bounds the complete successful parent text; child work remains outside the parent context.
#### KV Cache effect
Each fresh child has an independent request cache. The parent result appends after the reusable request prefix.
## Known Limitations and Deferred Work
- **Completion is worker self-declaration** — there is no independent evaluator or verifier deciding whether the objective is actually complete; evaluator policy and evaluator-driven continuation are deferred.
- **Foreground only** — there is no task id, background collection, process-resume checkpoint, scheduler, or wall-clock start policy.
- **The workspace is the only cross-round long-term memory** — one bounded report is the explicit handoff, and uncommitted conversational reasoning disappears with each child.
- **One round is one fresh child** — there is no within-round fan-out, model/provider switching, fork context, or model-call-selected provider.
- **Ordinary child failure is terminal for the run** — the fixed script reports the failed round and last successful handoff but does not retry; fatal workflow infrastructure failures can end before that state is returned.
- **Only round count bounds aggregate effort** — token, price, and elapsed-time budgets are deferred.
+53
View File
@@ -0,0 +1,53 @@
{
"name": "@deepseek-ai/dsh-tool-ralph",
"description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-workflow": "^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-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-inprocess": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-workflow": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
+456
View File
@@ -0,0 +1,456 @@
/**
* Model-facing foreground Ralph loop over the workflow and subagent seams. A
* fixed script starts one fresh structured-output child per round, carrying
* only the immutable objective and the previous bounded handoff between them.
* @module @deepseek-ai/dsh-tool-ralph
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools'
import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow'
// Declaration merge only: makes ctx.systemPrompt visible for section registration.
import type {} from '@deepseek-ai/dsh-system-prompt'
export const name = 'tool-ralph'
export const inject = ['tools', 'workflows', 'subagents', 'systemPrompt']
/** Deployment policy for the fixed Ralph workflow. */
export interface Config {
/** Fresh structured-output provider used for every round (default `spawn`). */
subagentProvider?: string
/** Default and deployment ceiling for one call's round count (default 256). */
maxRounds?: number
/** Maximum serialized characters in one structured handoff (default 16384). */
maxHandoffChars?: number
/** Maximum characters in a successful parent-facing terminal text (default 16384). */
maxResultChars?: number
}
/** Schemastery configuration for the Ralph tool. */
export const Config: z<Config> = z.object({
subagentProvider: z.string().default('spawn'),
maxRounds: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(256),
maxHandoffChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16_384),
maxResultChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16_384),
})
interface ResolvedConfig {
readonly subagentProvider: string
readonly maxRounds: number
readonly maxHandoffChars: number
readonly maxResultChars: number
}
type RalphRoundStatus = 'continue' | 'complete' | 'blocked'
interface RalphRoundReport {
readonly status: RalphRoundStatus
readonly summary: string
readonly evidence: string[]
readonly nextSteps: string[]
readonly blocker: string
}
type RalphRunStatus = 'complete' | 'blocked' | 'budget-limited'
interface RalphRunResult {
readonly status: RalphRunStatus
readonly roundsStarted: number
readonly report: RalphRoundReport
}
interface RalphRoundFailure {
readonly status: 'round-failed'
readonly roundsStarted: number
readonly lastReport?: RalphRoundReport
}
type RalphTerminalResult = RalphRunResult | RalphRoundFailure
interface RalphCallArgs {
objective: string
maxRounds?: number
}
const RALPH_META = {
name: 'ralph-loop',
description: 'Iterate toward one objective with a fresh child and bounded structured handoff per round.',
phases: [{ title: 'Fresh-agent rounds', detail: 'One clean child context per Ralph round.' }],
}
/**
* Fixed, deployment-owned orchestration. The model supplies data only; it
* cannot alter the loop, provider route, schema, or handoff validation.
*/
const RALPH_SCRIPT = String.raw`
const reportSchema = {
type: 'object',
properties: {
status: { type: 'string', enum: ['continue', 'complete', 'blocked'] },
summary: { type: 'string' },
evidence: { type: 'array', items: { type: 'string' } },
nextSteps: { type: 'array', items: { type: 'string' } },
blocker: { type: 'string' },
},
required: ['status', 'summary', 'evidence', 'nextSteps', 'blocker'],
additionalProperties: false,
}
function normalizedText(value) {
return typeof value === 'string' && value.length > 0 && value === value.trim()
}
function normalizedList(value) {
return Array.isArray(value) && value.every(normalizedText)
}
function validateReport(report) {
if (report === null || typeof report !== 'object' || Array.isArray(report)) {
throw new Error('Ralph child returned no structured round report')
}
if (!normalizedText(report.summary)) {
throw new Error('Ralph round report summary must be non-empty and normalized')
}
if (!normalizedList(report.evidence) || !normalizedList(report.nextSteps)) {
throw new Error('Ralph round report evidence and nextSteps must contain only non-empty normalized strings')
}
if (typeof report.blocker !== 'string' || report.blocker !== report.blocker.trim()) {
throw new Error('Ralph round report blocker must be a normalized string')
}
switch (report.status) {
case 'continue':
if (report.nextSteps.length === 0 || report.blocker !== '') {
throw new Error('a continuing Ralph report needs nextSteps and an empty blocker')
}
break
case 'complete':
if (report.evidence.length === 0 || report.nextSteps.length !== 0 || report.blocker !== '') {
throw new Error('a complete Ralph report needs evidence, no nextSteps, and an empty blocker')
}
break
case 'blocked':
if (!normalizedText(report.blocker)) {
throw new Error('a blocked Ralph report needs a concrete blocker')
}
break
default:
throw new Error('Ralph round report status is invalid')
}
const serialized = JSON.stringify(report)
if (serialized.length > args.maxHandoffChars) {
throw new Error('Ralph round report exceeds maxHandoffChars (' + serialized.length + ' > ' + args.maxHandoffChars + ')')
}
return report
}
let previous
phase('Fresh-agent rounds')
for (let round = 1; round <= args.maxRounds; round += 1) {
const prior = previous === undefined ? '(none — this is the first round)' : JSON.stringify(previous)
const prompt = [
'You are one fresh worker in a foreground Ralph loop. You receive no parent conversation and no prior child session. Do not call the ralph tool: this round already is its worker.',
'Immutable objective:\n' + args.objective,
'Ralph round: ' + round + ' of ' + args.maxRounds + '.',
'The shared workspace and its current working tree are the long-term memory and source of truth. Inspect them before acting, preserve existing work, perform concrete in-scope work, and verify what you change. Treat the previous report only as a bounded handoff; confirm it against the workspace.',
'Previous structured handoff:\n' + prior,
'Return one report with exact normalized strings. Use status continue with at least one nextSteps entry while useful work remains; complete only with concrete evidence and no nextSteps; blocked only when no meaningful progress is possible without human input or an external-state change. blocker must be empty unless blocked.',
].join('\n\n')
const rawReport = await agent(prompt, {
label: 'Ralph round ' + round,
phase: 'Fresh-agent rounds',
schema: reportSchema,
})
if (rawReport === null) {
return { status: 'round-failed', roundsStarted: round, lastReport: previous ?? null }
}
const report = validateReport(rawReport)
if (report.status === 'complete') return { status: 'complete', roundsStarted: round, report }
if (report.status === 'blocked') return { status: 'blocked', roundsStarted: round, report }
previous = report
}
return { status: 'budget-limited', roundsStarted: args.maxRounds, report: previous }
`
const DESCRIPTION = 'Run a foreground fresh-agent Ralph loop toward one immutable objective. '
+ 'Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round '
+ 'opens a new child with no parent conversation or prior child session; the shared workspace is '
+ 'long-term memory, and only a bounded structured report crosses rounds. The call returns when '
+ 'a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work '
+ 'belongs to goal tools.'
/** Validate defaults even when a caller invokes apply() without Loader normalization. */
function resolveConfig(config: Config): ResolvedConfig {
const subagentProvider = config.subagentProvider ?? 'spawn'
const maxRounds = config.maxRounds ?? 256
const maxHandoffChars = config.maxHandoffChars ?? 16_384
const maxResultChars = config.maxResultChars ?? 16_384
if (subagentProvider.length === 0 || subagentProvider !== subagentProvider.trim()) {
throw new TypeError('subagentProvider must be a non-empty normalized string')
}
if (!Number.isSafeInteger(maxRounds) || maxRounds < 1) {
throw new TypeError('maxRounds must be a positive safe integer')
}
if (!Number.isSafeInteger(maxHandoffChars) || maxHandoffChars < 1) {
throw new TypeError('maxHandoffChars must be a positive safe integer')
}
if (!Number.isSafeInteger(maxResultChars) || maxResultChars < 1) {
throw new TypeError('maxResultChars must be a positive safe integer')
}
return { subagentProvider, maxRounds, maxHandoffChars, maxResultChars }
}
/** Resolve one model-selected cap against the deployment ceiling. */
function resolveMaxRounds(requested: number | undefined, ceiling: number): number {
const value = requested ?? ceiling
if (!Number.isSafeInteger(value) || value < 1) {
throw new TypeError('Ralph maxRounds must be a positive safe integer')
}
if (value > ceiling) {
throw new TypeError(`Ralph maxRounds ${value} exceeds the deployment ceiling ${ceiling}`)
}
return value
}
/** Require the configured route to mean a genuinely fresh structured child. */
function requireFreshProvider(ctx: Context, name: string): SubagentProvider {
const provider = ctx.subagents.getProvider(name)
if (provider === undefined) {
throw new Error(`Ralph subagent provider "${name}" is not registered`)
}
if (!provider.capabilities.outputSchema) {
throw new Error(`Ralph subagent provider "${name}" does not support structured output`)
}
if (provider.inheritsParentContext) {
throw new Error(`Ralph subagent provider "${name}" inherits parent context; Ralph requires a fresh provider`)
}
return provider
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function normalizedText(value: unknown): value is string {
return typeof value === 'string' && value.length > 0 && value === value.trim()
}
function normalizedList(value: unknown): value is string[] {
return Array.isArray(value) && value.every(normalizedText)
}
/** Defensively decode the fixed script's report across an implementation seam. */
function readReport(value: unknown, expectedStatus: RalphRoundStatus, maxChars: number): RalphRoundReport {
if (!isRecord(value)
|| Object.keys(value).sort().join(',') !== 'blocker,evidence,nextSteps,status,summary'
|| value['status'] !== expectedStatus
|| !normalizedText(value['summary'])
|| !normalizedList(value['evidence'])
|| !normalizedList(value['nextSteps'])
|| typeof value['blocker'] !== 'string'
|| value['blocker'] !== value['blocker'].trim()) {
throw new Error('Ralph workflow returned a malformed round report')
}
const report: RalphRoundReport = {
status: expectedStatus,
summary: value['summary'],
evidence: value['evidence'],
nextSteps: value['nextSteps'],
blocker: value['blocker'],
}
if (expectedStatus === 'continue' && (report.nextSteps.length === 0 || report.blocker !== '')) {
throw new Error('Ralph workflow returned an invalid continuing report')
}
if (expectedStatus === 'complete'
&& (report.evidence.length === 0 || report.nextSteps.length !== 0 || report.blocker !== '')) {
throw new Error('Ralph workflow returned an invalid completion report')
}
if (expectedStatus === 'blocked' && !normalizedText(report.blocker)) {
throw new Error('Ralph workflow returned an invalid blocked report')
}
const chars = JSON.stringify(report).length
if (chars > maxChars) {
throw new Error(`Ralph workflow returned an oversized handoff (${chars} > ${maxChars})`)
}
return report
}
/** Defensively decode the fixed script's terminal value. */
function readRunResult(value: unknown, maxRounds: number, maxHandoffChars: number): RalphTerminalResult {
if (!isRecord(value)
|| typeof value['roundsStarted'] !== 'number'
|| !Number.isSafeInteger(value['roundsStarted'])
|| value['roundsStarted'] < 1
|| value['roundsStarted'] > maxRounds) {
throw new Error('Ralph workflow returned a malformed terminal result')
}
const roundsStarted = value['roundsStarted']
switch (value['status']) {
case 'complete':
if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') {
throw new Error('Ralph workflow returned a malformed terminal result')
}
return { status: 'complete', roundsStarted, report: readReport(value['report'], 'complete', maxHandoffChars) }
case 'blocked':
if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') {
throw new Error('Ralph workflow returned a malformed terminal result')
}
return { status: 'blocked', roundsStarted, report: readReport(value['report'], 'blocked', maxHandoffChars) }
case 'budget-limited':
if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') {
throw new Error('Ralph workflow returned a malformed terminal result')
}
if (roundsStarted !== maxRounds) {
throw new Error('Ralph workflow returned budget-limited before the round limit')
}
return { status: 'budget-limited', roundsStarted, report: readReport(value['report'], 'continue', maxHandoffChars) }
case 'round-failed': {
if (Object.keys(value).sort().join(',') !== 'lastReport,roundsStarted,status') {
throw new Error('Ralph workflow returned a malformed terminal result')
}
if (roundsStarted === 1) {
if (value['lastReport'] !== null) {
throw new Error('Ralph workflow returned an invalid first-round failure')
}
return { status: 'round-failed', roundsStarted }
}
if (value['lastReport'] === null) {
throw new Error('Ralph workflow returned a round failure without its last handoff')
}
return {
status: 'round-failed',
roundsStarted,
lastReport: readReport(value['lastReport'], 'continue', maxHandoffChars),
}
}
default:
throw new Error('Ralph workflow returned an unknown terminal status')
}
}
/** A non-clean workflow finish is an error, never a partial Ralph success. */
function stopReasonError(result: WorkflowResult): string | undefined {
switch (result.stopReason) {
case 'completed':
return undefined
case 'cancelled':
return `Ralph workflow was cancelled${result.error === undefined ? '' : ` (${result.error})`}`
case 'error':
return `Ralph workflow failed: ${result.error ?? 'unknown error'}`
/* v8 ignore start -- WorkflowStopReason is closed; a future variant must fail loud here. */
default:
return `Ralph workflow ended abnormally (${String(result.stopReason satisfies never)})`
/* v8 ignore stop */
}
}
const TRUNCATION_NOTICE = '\n… [truncated]'
/** Bound complete parent-facing text, including its envelope and truncation marker. */
function boundResult(text: string, maxChars: number): string {
if (text.length <= maxChars) return text
if (maxChars <= TRUNCATION_NOTICE.length) return TRUNCATION_NOTICE.slice(0, maxChars)
return `${text.slice(0, maxChars - TRUNCATION_NOTICE.length)}${TRUNCATION_NOTICE}`
}
/** Render the fixed terminal envelope without presenting self-report as certification. */
function renderResult(result: RalphRunResult, maxChars: number): string {
const rounds = `${result.roundsStarted} round${result.roundsStarted === 1 ? '' : 's'}`
let text: string
switch (result.status) {
case 'complete':
text = `Ralph worker reported completion after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
break
case 'blocked':
text = `Ralph worker reported a blocker after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
break
case 'budget-limited':
text = `Ralph reached its ${rounds} limit; the worker reported work remaining.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
break
}
return boundResult(text, maxChars)
}
/** Render an ordinary child failure with the most recent durable handoff. */
function renderRoundFailure(result: RalphRoundFailure, maxChars: number): string {
const header = `Ralph round ${result.roundsStarted} child failed before producing a structured report.`
const text = result.lastReport === undefined
? `${header}\nNo previous handoff was available.`
: `${header}\nLast successful handoff:\n${JSON.stringify(result.lastReport, null, 2)}`
return boundResult(text, maxChars)
}
function presentCall(args: RalphCallArgs): ToolCallView {
return { card: 'generic', title: 'ralph', rawInput: args.objective }
}
function presentResult(args: RalphCallArgs, result: { content: ContentBlock[]; isError: boolean }): ToolResultView {
void args
void result
return { card: 'generic' }
}
/** Register the fixed Ralph tool and its explicit-ask usage policy. */
export function apply(ctx: Context, config: Config): void {
const resolved = resolveConfig(config)
ctx.systemPrompt.section({
name: 'tool:ralph',
order: 116,
text: 'Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.',
})
ctx.tools.register(defineTool({
name: 'ralph',
description: DESCRIPTION,
parameters: {
objective: {
type: 'string',
required: true,
description: 'The immutable completion objective for every fresh Ralph round.',
},
maxRounds: {
type: 'number',
description: 'Optional positive safe-integer round cap, bounded by the deployment ceiling.',
},
},
async execute(args, exec): Promise<ContentBlock[]> {
const parent = exec.agent
if (parent === undefined) {
throw new Error('Ralph tool requires a calling agent (exec.agent was undefined)')
}
const objective = args.objective.trim()
if (objective.length === 0) throw new Error('Ralph objective must be a non-empty string')
const maxRounds = resolveMaxRounds(args.maxRounds, resolved.maxRounds)
void requireFreshProvider(ctx, resolved.subagentProvider)
const run: WorkflowRun = ctx.workflows.start({
script: RALPH_SCRIPT,
meta: RALPH_META,
args: { objective, maxRounds, maxHandoffChars: resolved.maxHandoffChars },
subagentProvider: resolved.subagentProvider,
maxTotalAgents: maxRounds,
parent,
...exec.signal === undefined ? {} : { signal: exec.signal },
})
const onAbort = (): void => { run.cancel('parent step aborted') }
exec.signal?.addEventListener('abort', onAbort, { once: true })
if (exec.signal?.aborted) run.cancel('parent step aborted')
try {
const settled = await run.result
const error = stopReasonError(settled)
if (error !== undefined) throw new Error(error)
const value = readRunResult(settled.value, maxRounds, resolved.maxHandoffChars)
if (value.status === 'round-failed') throw new Error(renderRoundFailure(value, resolved.maxResultChars))
return [{ type: 'text', text: renderResult(value, resolved.maxResultChars) }]
} finally {
exec.signal?.removeEventListener('abort', onAbort)
await run.dispose()
}
},
presentCall,
presentResult,
}))
}
@@ -0,0 +1,270 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import SubagentService from '@deepseek-ai/dsh-subagent'
import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
import * as spawn from '@deepseek-ai/dsh-subagent-spawn'
import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as toolRalph from '../src/index.ts'
type MockScript = ConstructorParameters<typeof MockAdapter>[0]
/** Mount the shipped Ralph execution stack around one keyless model script. */
async function mountRalph(script: MockScript, config: toolRalph.Config) {
const ctx = new Context()
const adapter = new MockAdapter(script)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(spawn, { providerName: 'spawn' })
await ctx.plugin(WorkerWorkflowEngine, {})
await ctx.plugin(toolRalph, config)
ctx.llm.registerAdapter(['mock'], adapter)
const parentHandle = await ctx.agents.create({
sessionId: SessionId('ralph-parent'),
meta: { cwd: '/tmp/ralph-shared-workspace' },
agentOptions: { provider: 'mock', model: 'mock' },
})
return { ctx, adapter, parentHandle, parent: parentHandle.agent }
}
describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => {
it('uses distinct empty-seed children, shared cwd, and only the prior bounded handoff', async () => {
const firstReport = {
status: 'continue',
summary: 'ROUND_ONE_HANDOFF',
evidence: ['Created migration-a.ts.'],
nextSteps: ['Finish migration-b.ts.'],
blocker: '',
}
const finalReport = {
status: 'complete',
summary: 'Both migration slices are complete.',
evidence: ['Focused migration tests pass.'],
nextSteps: [],
blocker: '',
}
const ctx = new Context()
const adapter = new MockAdapter([
textResponse('PARENT_HISTORY_MARKER'),
toolCallResponse('round-1', STRUCTURED_OUTPUT_TOOL, firstReport),
toolCallResponse('round-2', STRUCTURED_OUTPUT_TOOL, finalReport),
])
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(spawn, { providerName: 'spawn' })
await ctx.plugin(WorkerWorkflowEngine, {})
await ctx.plugin(toolRalph, { maxRounds: 2 })
ctx.llm.registerAdapter(['mock'], adapter)
const parentHandle = await ctx.agents.create({
sessionId: SessionId('ralph-parent'),
meta: { cwd: '/tmp/ralph-shared-workspace' },
agentOptions: { provider: 'mock', model: 'mock' },
})
const parent = parentHandle.agent
parent.send([{ type: 'text', text: 'PARENT_PROMPT_MARKER' }])
await parent.whenIdle()
const children: Agent[] = []
const phases: string[] = []
ctx.on('workflow/phase', (_run, title) => { phases.push(title) })
ctx.on('workflow/agent-start', (_run, child) => {
const agent = ctx.agents.get(child.childId)
expect(agent).toBeDefined()
children.push(agent!)
})
const result = await ctx.tools.execute({
callId: CallId('ralph-integration'),
name: 'ralph',
arguments: { objective: 'Complete both migration slices.', maxRounds: 2 },
agent: parent,
})
expect(result.isError).toBe(false)
expect((result.content[0] as { text: string }).text)
.toContain('Ralph worker reported completion after 2 rounds.')
expect(phases).toEqual(['Fresh-agent rounds'])
expect(children).toHaveLength(2)
expect(new Set(children.map(child => child.id)).size).toBe(2)
for (const child of children) {
expect(child.session.header.cwd).toBe('/tmp/ralph-shared-workspace')
expect(child.session.header.parentSession).toBe(parent.session.header.id)
expect(child.session.header.seedLength).toBeUndefined()
expect(ctx.agents.get(child.id)).toBeUndefined()
}
expect(adapter.requests).toHaveLength(3)
const firstChildRequest = JSON.stringify(adapter.requests[1]!.messages)
const secondChildRequest = JSON.stringify(adapter.requests[2]!.messages)
expect(firstChildRequest).not.toContain('PARENT_PROMPT_MARKER')
expect(firstChildRequest).not.toContain('PARENT_HISTORY_MARKER')
expect(firstChildRequest).not.toContain('ROUND_ONE_HANDOFF')
expect(secondChildRequest).not.toContain('PARENT_PROMPT_MARKER')
expect(secondChildRequest).not.toContain('PARENT_HISTORY_MARKER')
expect(secondChildRequest).toContain('ROUND_ONE_HANDOFF')
await parentHandle.dispose()
})
it('reports the failed round and last good handoff when a child fails', async () => {
const firstReport = {
status: 'continue',
summary: 'ROUND_ONE_HANDOFF',
evidence: ['Created migration-a.ts.'],
nextSteps: ['Finish migration-b.ts.'],
blocker: '',
}
const { ctx, parent, parentHandle } = await mountRalph([
toolCallResponse('round-1', STRUCTURED_OUTPUT_TOOL, firstReport),
maxTokensResponse('unfinished child output'),
], { maxRounds: 2 })
const children: Agent[] = []
ctx.on('workflow/agent-start', (_run, child) => {
const agent = ctx.agents.get(child.childId)
if (agent !== undefined) children.push(agent)
})
const result = await ctx.tools.execute({
callId: CallId('ralph-child-failure'),
name: 'ralph',
arguments: { objective: 'Complete both migration slices.', maxRounds: 2 },
agent: parent,
})
expect(result.isError).toBe(true)
const text = (result.content[0] as { text: string }).text
expect(text).toContain('Ralph round 2 child failed before producing a structured report.')
expect(text).toContain('Last successful handoff:')
expect(text).toContain('ROUND_ONE_HANDOFF')
expect(children).toHaveLength(2)
for (const child of children) expect(ctx.agents.get(child.id)).toBeUndefined()
await parentHandle.dispose()
})
it.each([
{
name: 'blocked',
report: {
status: 'blocked',
summary: 'External authorization is required.',
evidence: ['The local implementation is ready.'],
nextSteps: ['Continue after authorization.'],
blocker: 'The required external authorization is unavailable.',
},
config: { maxRounds: 2 },
expectedError: false,
expectedText: 'Ralph worker reported a blocker after 1 round.',
},
{
name: 'budget-limited',
report: {
status: 'continue',
summary: 'One slice is complete.',
evidence: ['The first focused test passes.'],
nextSteps: ['Implement the remaining slice.'],
blocker: '',
},
config: { maxRounds: 1 },
expectedError: false,
expectedText: 'Ralph reached its 1 round limit; the worker reported work remaining.',
},
{
name: 'unnormalized report',
report: {
status: 'continue',
summary: ' padded summary ',
evidence: ['A focused test passes.'],
nextSteps: ['Continue implementation.'],
blocker: '',
},
config: { maxRounds: 1 },
expectedError: true,
expectedText: 'summary must be non-empty and normalized',
},
{
name: 'invalid continuing report',
report: {
status: 'continue',
summary: 'Work remains.',
evidence: ['A focused test passes.'],
nextSteps: [],
blocker: '',
},
config: { maxRounds: 1 },
expectedError: true,
expectedText: 'a continuing Ralph report needs nextSteps and an empty blocker',
},
{
name: 'oversized report',
report: {
status: 'continue',
summary: 'x'.repeat(300),
evidence: ['A focused test passes.'],
nextSteps: ['Continue implementation.'],
blocker: '',
},
config: { maxRounds: 1, maxHandoffChars: 100 },
expectedError: true,
expectedText: 'Ralph round report exceeds maxHandoffChars',
},
])('enforces the fixed script for $name', async ({ report, config, expectedError, expectedText }) => {
const { ctx, parent, parentHandle } = await mountRalph([
toolCallResponse('round-report', STRUCTURED_OUTPUT_TOOL, report),
], config)
const result = await ctx.tools.execute({
callId: CallId('ralph-script-enforcement'),
name: 'ralph',
arguments: { objective: 'Complete the scoped work.', maxRounds: config.maxRounds },
agent: parent,
})
expect(result.isError).toBe(expectedError)
expect((result.content[0] as { text: string }).text).toContain(expectedText)
await parentHandle.dispose()
})
it('cancels the real worker and fresh child to quiescence', { timeout: 20_000 }, async () => {
const { ctx, parent, parentHandle } = await mountRalph(['hang'], { maxRounds: 2 })
const children: Agent[] = []
const outcomes: string[] = []
let resolveChildStarted!: (child: Agent) => void
const childStarted = new Promise<Agent>((resolve) => { resolveChildStarted = resolve })
ctx.on('workflow/agent-start', (_run, child) => {
const agent = ctx.agents.get(child.childId)
if (agent !== undefined) {
children.push(agent)
resolveChildStarted(agent)
}
})
ctx.on('workflow/agent-end', (_run, child) => { outcomes.push(child.outcome) })
const controller = new AbortController()
const pending = ctx.tools.execute({
callId: CallId('ralph-real-cancel'),
name: 'ralph',
arguments: { objective: 'Keep working until cancelled.', maxRounds: 2 },
agent: parent,
signal: controller.signal,
})
await childStarted
controller.abort()
const result = await pending
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('Ralph workflow was cancelled')
expect(outcomes).toEqual(['cancelled'])
expect(ctx.agents.get(children[0]!.id)).toBeUndefined()
await parentHandle.dispose()
})
})
@@ -0,0 +1,380 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentCapabilities, SubagentProvider, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
import * as toolRalph from '../src/index.ts'
class StubEngine extends WorkflowService {
requests: WorkflowStartRequest[] = []
cancels: string[] = []
disposed = 0
settle!: (result: WorkflowResult) => void
startError: Error | undefined
start(request: WorkflowStartRequest): WorkflowRun {
if (this.startError !== undefined) throw this.startError
this.requests.push(request)
const result = new Promise<WorkflowResult>((resolve) => { this.settle = resolve })
return {
id: WorkflowRunId(`ralph-${this.requests.length}`),
meta: request.meta,
result,
cancel: (reason?: string) => {
this.cancels.push(reason ?? 'cancelled')
this.settle({
value: null,
stopReason: 'cancelled',
...reason === undefined ? {} : { error: reason },
agentsStarted: 0,
})
},
dispose: () => {
this.disposed += 1
return Promise.resolve()
},
}
}
}
class StubProvider implements SubagentProvider {
readonly name = 'fresh'
readonly capabilities: SubagentCapabilities
readonly inheritsParentContext: boolean
constructor(options?: { outputSchema?: boolean; inheritsParentContext?: boolean }) {
this.capabilities = {
outputSchema: options?.outputSchema ?? true,
depthLimit: true,
toolFilter: true,
persona: true,
}
this.inheritsParentContext = options?.inheritsParentContext ?? false
}
start(_request: SubagentStartRequest): Promise<SubagentRun> {
return Promise.reject(new Error('StubProvider.start must not be reached behind StubEngine'))
}
}
interface SetupOptions {
config?: toolRalph.Config
provider?: StubProvider | false
}
async function setup(options?: SetupOptions) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
const provider = options?.provider === false ? undefined : options?.provider ?? new StubProvider()
if (provider !== undefined) ctx.subagents.registerProvider(provider)
await ctx.plugin(StubEngine)
const config: toolRalph.Config = { subagentProvider: 'fresh' }
if (options?.config?.subagentProvider !== undefined) config.subagentProvider = options.config.subagentProvider
if (options?.config?.maxRounds !== undefined) config.maxRounds = options.config.maxRounds
if (options?.config?.maxHandoffChars !== undefined) config.maxHandoffChars = options.config.maxHandoffChars
if (options?.config?.maxResultChars !== undefined) config.maxResultChars = options.config.maxResultChars
const fiber = await ctx.plugin(toolRalph, config)
const parent = { id: SessionId('caller'), options: {} } as unknown as Agent
return { ctx, engine: ctx.workflows as StubEngine, parent, fiber }
}
function execute(
ctx: Context,
args: unknown,
extra?: { agent?: Agent; signal?: AbortSignal },
): Promise<ToolExecutionResult> {
return ctx.tools.execute({
callId: CallId('ralph-call'),
name: 'ralph',
arguments: args,
...extra?.agent === undefined ? {} : { agent: extra.agent },
...extra?.signal === undefined ? {} : { signal: extra.signal },
})
}
const CONTINUE = {
status: 'continue',
summary: 'Implemented the first slice.',
evidence: ['Focused tests pass.'],
nextSteps: ['Implement the second slice.'],
blocker: '',
}
const COMPLETE = {
status: 'complete',
summary: 'The objective is complete.',
evidence: ['All required gates pass.'],
nextSteps: [],
blocker: '',
}
const BLOCKED = {
status: 'blocked',
summary: 'No local work can progress.',
evidence: ['The required remote service is unavailable.'],
nextSteps: ['Retry after service recovery.'],
blocker: 'The required remote service is unavailable.',
}
async function settleCompleted(
engine: StubEngine,
pending: Promise<ToolExecutionResult>,
value: unknown,
agentsStarted = 1,
): Promise<ToolExecutionResult> {
await vi.waitFor(() => { expect(engine.requests.length).toBeGreaterThan(0) })
engine.settle({ value, stopReason: 'completed', agentsStarted })
return pending
}
describe('dsh-tool-ralph', () => {
it('starts the fixed workflow through the configured fresh provider and renders completion', async () => {
const { ctx, engine, parent } = await setup({ config: { maxRounds: 9, maxHandoffChars: 9000 } })
const pending = execute(ctx, { objective: ' Finish the migration. ', maxRounds: 4 }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
expect(engine.requests[0]).toMatchObject({
meta: { name: 'ralph-loop' },
args: { objective: 'Finish the migration.', maxRounds: 4, maxHandoffChars: 9000 },
subagentProvider: 'fresh',
maxTotalAgents: 4,
parent,
})
expect(engine.requests[0]!.script).toContain("status: 'budget-limited'")
const result = await settleCompleted(engine, pending, {
status: 'complete',
roundsStarted: 1,
report: COMPLETE,
})
expect(result.isError).toBe(false)
expect((result.content[0] as { text: string }).text)
.toContain('Ralph worker reported completion after 1 round.')
expect((result.content[0] as { text: string }).text).toContain('All required gates pass.')
expect(engine.disposed).toBe(1)
})
it('renders blocked and budget-limited terminal outcomes as bounded successful results', async () => {
const { ctx, engine, parent } = await setup({ config: { maxRounds: 2 } })
const blocked = execute(ctx, { objective: 'Ship it.' }, { agent: parent })
const blockedResult = await settleCompleted(engine, blocked, {
status: 'blocked',
roundsStarted: 2,
report: BLOCKED,
}, 2)
expect((blockedResult.content[0] as { text: string }).text)
.toContain('Ralph worker reported a blocker after 2 rounds.')
const limited = execute(ctx, { objective: 'Ship it.' }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) })
const limitedResult = await settleCompleted(engine, limited, {
status: 'budget-limited',
roundsStarted: 2,
report: CONTINUE,
}, 2)
expect((limitedResult.content[0] as { text: string }).text)
.toContain('Ralph reached its 2 rounds limit; the worker reported work remaining.')
})
it('bounds the complete parent result and labels worker-reported completion', async () => {
const { ctx, engine, parent } = await setup({ config: { maxResultChars: 160 } })
const pending = execute(ctx, { objective: 'Ship it.' }, { agent: parent })
const result = await settleCompleted(engine, pending, {
status: 'complete',
roundsStarted: 1,
report: { ...COMPLETE, evidence: ['x'.repeat(500)] },
})
const text = (result.content[0] as { text: string }).text
expect(text).toHaveLength(160)
expect(text).toContain('Ralph worker reported completion after 1 round.')
expect(text).toMatch(/… \[truncated\]$/)
})
it('honors a result limit shorter than the truncation marker', async () => {
const { ctx, engine, parent } = await setup({ config: { maxResultChars: 5 } })
const result = await settleCompleted(engine, execute(ctx, { objective: 'Ship it.' }, { agent: parent }), {
status: 'complete',
roundsStarted: 1,
report: COMPLETE,
})
expect((result.content[0] as { text: string }).text).toBe('\n… [t')
})
it('reports an ordinary child failure with the failed round and last durable handoff', async () => {
const { ctx, engine, parent } = await setup({ config: { maxRounds: 2 } })
const first = execute(ctx, { objective: 'Ship it.', maxRounds: 2 }, { agent: parent })
const firstResult = await settleCompleted(engine, first, {
status: 'round-failed',
roundsStarted: 1,
lastReport: null,
})
expect(firstResult.isError).toBe(true)
expect((firstResult.content[0] as { text: string }).text).toContain('Ralph round 1 child failed')
expect((firstResult.content[0] as { text: string }).text).toContain('No previous handoff was available.')
const later = execute(ctx, { objective: 'Ship it.', maxRounds: 2 }, { agent: parent })
const laterResult = await settleCompleted(engine, later, {
status: 'round-failed',
roundsStarted: 2,
lastReport: CONTINUE,
})
expect(laterResult.isError).toBe(true)
expect((laterResult.content[0] as { text: string }).text).toContain('Ralph round 2 child failed')
expect((laterResult.content[0] as { text: string }).text).toContain('Implemented the first slice.')
})
it('maps workflow error and cancellation reasons to tool errors and always disposes', async () => {
const { ctx, engine, parent } = await setup()
const failed = execute(ctx, { objective: 'Work.' }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
engine.settle({ value: null, stopReason: 'error', error: 'child report malformed', agentsStarted: 1 })
expect(((await failed).content[0] as { text: string }).text)
.toContain('Ralph workflow failed: child report malformed')
const unknown = execute(ctx, { objective: 'Work.' }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) })
engine.settle({ value: null, stopReason: 'error', agentsStarted: 0 })
expect(((await unknown).content[0] as { text: string }).text).toContain('unknown error')
const cancelled = execute(ctx, { objective: 'Work.' }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(3) })
engine.settle({ value: null, stopReason: 'cancelled', error: 'user stopped', agentsStarted: 0 })
expect(((await cancelled).content[0] as { text: string }).text).toContain('cancelled (user stopped)')
const bare = execute(ctx, { objective: 'Work.' }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(4) })
engine.settle({ value: null, stopReason: 'cancelled', agentsStarted: 0 })
expect(((await bare).content[0] as { text: string }).text).toMatch(/cancelled$/)
expect(engine.disposed).toBe(4)
})
it('bridges mid-flight and already-aborted parent signals to cancellation', async () => {
const { ctx, engine, parent } = await setup()
const controller = new AbortController()
const pending = execute(ctx, { objective: 'Work.' }, { agent: parent, signal: controller.signal })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
controller.abort()
expect((await pending).isError).toBe(true)
const already = new AbortController()
already.abort()
expect((await execute(ctx, { objective: 'Work.' }, { agent: parent, signal: already.signal })).isError).toBe(true)
expect(engine.cancels).toEqual(['parent step aborted', 'parent step aborted'])
expect(engine.disposed).toBe(2)
})
it('rejects absent authority, empty objectives, bad round caps, and schema-invalid calls before start', async () => {
const { ctx, engine, parent } = await setup({ config: { maxRounds: 3 } })
expect((await execute(ctx, { objective: 'Work.' })).isError).toBe(true)
expect((await execute(ctx, { objective: ' ' }, { agent: parent })).isError).toBe(true)
for (const maxRounds of [0, 1.5, Number.NaN, 4]) {
expect((await execute(ctx, { objective: 'Work.', maxRounds }, { agent: parent })).isError).toBe(true)
}
const missing = await execute(ctx, {}, { agent: parent })
expect(missing.error?.code).toBe('INVALID_ARGS')
expect(engine.requests).toHaveLength(0)
})
it('rejects missing, unstructured, and parent-context-inheriting provider routes', async () => {
const missing = await setup({ provider: false })
expect(((await execute(missing.ctx, { objective: 'Work.' }, { agent: missing.parent })).content[0] as { text: string }).text)
.toContain('is not registered')
expect(missing.engine.requests).toHaveLength(0)
const unstructured = await setup({ provider: new StubProvider({ outputSchema: false }) })
expect(((await execute(unstructured.ctx, { objective: 'Work.' }, { agent: unstructured.parent })).content[0] as { text: string }).text)
.toContain('does not support structured output')
const inherited = await setup({ provider: new StubProvider({ inheritsParentContext: true }) })
expect(((await execute(inherited.ctx, { objective: 'Work.' }, { agent: inherited.parent })).content[0] as { text: string }).text)
.toContain('inherits parent context')
})
it('rejects invalid direct-apply config before touching injected services', () => {
expect(() => { toolRalph.apply(new Context(), { subagentProvider: ' ' }) }).toThrow('non-empty normalized')
expect(() => { toolRalph.apply(new Context(), { maxRounds: 0 }) }).toThrow('positive safe integer')
expect(() => { toolRalph.apply(new Context(), { maxHandoffChars: 1.5 }) }).toThrow('positive safe integer')
expect(() => { toolRalph.apply(new Context(), { maxResultChars: 0 }) }).toThrow('positive safe integer')
})
it('turns malformed fixed-workflow terminal values and reports into errors', async () => {
const cases: { value: unknown; message: string; config?: toolRalph.Config }[] = [
{ value: null, message: 'malformed terminal result' },
{ value: { status: 'complete', roundsStarted: 0, report: COMPLETE }, message: 'malformed terminal result' },
{ value: { status: 'complete', roundsStarted: 3, report: COMPLETE }, message: 'malformed terminal result', config: { maxRounds: 2 } },
{ value: { status: 'mystery', roundsStarted: 1, report: COMPLETE }, message: 'unknown terminal status' },
{ value: { status: 'budget-limited', roundsStarted: 1, report: CONTINUE }, message: 'before the round limit', config: { maxRounds: 2 } },
{ value: { status: 'complete', roundsStarted: 1, report: null }, message: 'malformed round report' },
{ value: { status: 'complete', roundsStarted: 1, report: COMPLETE, extra: true }, message: 'malformed terminal result' },
{ value: { status: 'blocked', roundsStarted: 1, report: BLOCKED, extra: true }, message: 'malformed terminal result' },
{ value: { status: 'budget-limited', roundsStarted: 1, report: CONTINUE, extra: true }, message: 'malformed terminal result', config: { maxRounds: 1 } },
{ value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, status: 'continue' } }, message: 'malformed round report' },
{ value: { status: 'budget-limited', roundsStarted: 1, report: { ...CONTINUE, nextSteps: [] } }, message: 'invalid continuing report', config: { maxRounds: 1 } },
{ value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, evidence: [] } }, message: 'invalid completion report' },
{ value: { status: 'blocked', roundsStarted: 1, report: { ...BLOCKED, blocker: '' } }, message: 'invalid blocked report' },
{ value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, summary: 'x'.repeat(500) } }, message: 'oversized handoff', config: { maxHandoffChars: 100 } },
{ value: { status: 'round-failed', roundsStarted: 1 }, message: 'malformed terminal result' },
{ value: { status: 'round-failed', roundsStarted: 1, lastReport: CONTINUE }, message: 'invalid first-round failure' },
{ value: { status: 'round-failed', roundsStarted: 2, lastReport: null }, message: 'without its last handoff', config: { maxRounds: 2 } },
{ value: { status: 'round-failed', roundsStarted: 2, lastReport: { ...CONTINUE, nextSteps: [] } }, message: 'invalid continuing report', config: { maxRounds: 2 } },
]
for (const testCase of cases) {
const { ctx, engine, parent } = await setup(
testCase.config === undefined ? undefined : { config: testCase.config },
)
const result = await settleCompleted(
engine,
execute(ctx, { objective: 'Work.', ...testCase.config?.maxRounds === undefined ? {} : { maxRounds: testCase.config.maxRounds } }, { agent: parent }),
testCase.value,
)
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain(testCase.message)
}
})
it('surfaces a synchronous engine start failure without inventing a run', async () => {
const { ctx, engine, parent } = await setup()
engine.startError = new Error('engine refused fixed script')
const result = await execute(ctx, { objective: 'Work.' }, { agent: parent })
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('engine refused fixed script')
expect(engine.disposed).toBe(0)
})
it('registers scoped guidance and pure replay-safe generic presentation', async () => {
const { ctx, fiber } = await setup()
const section = (await ctx.systemPrompt.assemble()).sections.find(candidate => candidate.name === 'tool:ralph')
expect(section?.text).toContain('ONLY when the direct human explicitly asks')
expect(section?.text).toContain('worker reports, not independent evaluation')
const tool = ctx.tools.get('ralph')!
expect(tool.description).toContain('worker reports completion')
expect(tool.presentCall!({ objective: 'Finish it.' })).toEqual({
card: 'generic',
title: 'ralph',
rawInput: 'Finish it.',
})
expect(tool.presentResult!({ objective: 'Finish it.' }, { content: [], isError: false })).toEqual({ card: 'generic' })
expect(tool.presentCall!({ nope: true })).toBeUndefined()
await fiber.dispose()
expect(ctx.tools.get('ralph')).toBeUndefined()
expect((await ctx.systemPrompt.assemble()).sections.some(candidate => candidate.name === 'tool:ralph')).toBe(false)
})
it('has the namespace-plugin export shape', () => {
expect('default' in toolRalph).toBe(false)
expect(toolRalph.name).toBe('tool-ralph')
expect(toolRalph.inject).toEqual(['tools', 'workflows', 'subagents', 'systemPrompt'])
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(toolRalph) as Record<string, unknown>
expect(unwrapped).toBe(toolRalph)
expect(typeof unwrapped.apply).toBe('function')
})
})
@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../subagent/subagent"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/tools"
},
{
"path": "../workflow"
}
]
}
@@ -230,6 +230,12 @@ describe('dsh-tool-workflow', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'spawn',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
inheritsParentContext: false,
start: () => Promise.reject(new Error('the parked-script fixture must not start a child')),
})
await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 })
await ctx.plugin(toolWorkflow, {})
const parent = { id: SessionId('caller'), options: {} } as unknown as Agent
@@ -34,12 +34,12 @@ Unknown options, malformed arguments, unsupported schemas, tripped caps, provide
## Run sequence
`start()` validates meta and parses the body, creates the worker, and returns a holder-owned `WorkflowRun`. Source mode installs TypeScript transforms through a data-URL bootstrap; built mode passes sibling `lib/worker.cjs` as a filesystem path because pkg's VFS hook expects CommonJS. Both work under ordinary Node. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice.
`start()` validates meta, parses the body, resolves a registered normalized provider route, and resolves any per-run total-child cap before creating a worker or publishing `workflow/start`. A requested `maxTotalAgents` must be a positive safe integer no greater than the engine's configured deployment ceiling. Source mode installs TypeScript transforms through a data-URL bootstrap; built mode passes sibling `lib/worker.cjs` as a filesystem path because pkg's VFS hook expects CommonJS. Both work under ordinary Node. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice.
For each `agent()` call:
1. The worker sends `child-start` with a plain-data prompt and options.
2. The host calls the configured provider through async `SubagentService.start`, passing the workflow's parent and one canonical per-run abort signal.
2. The host calls the start request's provider override, or otherwise the configured provider, through async `SubagentService.start`, passing the workflow's parent and one canonical per-run abort signal. Provider choice applies to every child in that run and is not visible to the script.
3. If start rejects, the host sends `child-start-error`; provider startup has already reached quiescence and no child lifecycle event is emitted.
4. If start fulfills while the workflow still admits work, the host records the run, observes `result`, then sends `child-started`. Even an already-settled result is forwarded afterward, preserving start-before-result order.
5. The worker emits paired `workflow/agent-start` and `workflow/agent-end` narration and requests child disposal after collection.
@@ -81,6 +81,8 @@ The host keeps a ledger of forwarded child starts. A graceful worker supplies th
| `syncTimeoutMs` | `5000` | VM timeout for the script's initial synchronous slice. |
| `disposeGraceMs` | `5000` | Bound before force-settlement/termination and for public disposal. |
An owning consumer may set `WorkflowStartRequest.subagentProvider` and `WorkflowStartRequest.maxTotalAgents` for one run. These are engine-level policy, not script hooks or model-facing options; the ordinary `workflow` tool leaves both unset. A per-run total-child cap may lower but never raise the configured `maxTotalAgents` ceiling.
## Model Experience
### Child-agent requests
@@ -73,6 +73,36 @@ function assertBodyParses(body: string, name: string): void {
}
}
/** Resolve one run's provider route before publishing work. */
function resolveSubagentProvider(ctx: Context, configured: string, override: string | undefined): string {
const provider = override ?? configured
if (provider.length === 0 || provider !== provider.trim()) {
throw new WorkflowError(
'workflow subagentProvider must be a non-empty normalized string',
'INVALID_ARGUMENT',
)
}
if (ctx.subagents.getProvider(provider) === undefined) {
throw new WorkflowError(`no subagent provider registered for "${provider}"`, 'AGENT_START')
}
return provider
}
/** Resolve one run's total-child cap against the engine deployment ceiling. */
function resolveMaxTotalAgents(requested: number | undefined, ceiling: number): number {
if (requested === undefined) return ceiling
if (!Number.isSafeInteger(requested) || requested < 1) {
throw new WorkflowError('workflow maxTotalAgents must be a positive safe integer', 'INVALID_ARGUMENT')
}
if (requested > ceiling) {
throw new WorkflowError(
`workflow maxTotalAgents ${requested} exceeds the engine ceiling ${ceiling}`,
'INVALID_ARGUMENT',
)
}
return requested
}
/**
* The worker-thread engine service. `start()` validates the script up front
* (meta + a host-side body parse) and returns a {@link WorkflowRun} whose
@@ -113,13 +143,15 @@ class WorkerWorkflowEngine extends WorkflowService {
start(request: WorkflowStartRequest): WorkflowRun {
const meta = validateMeta(request.meta)
assertBodyParses(request.script, meta.name)
const subagentProvider = resolveSubagentProvider(this.ctx, this.config.provider, request.subagentProvider)
const maxTotalAgents = resolveMaxTotalAgents(request.maxTotalAgents, this.config.maxTotalAgents)
const id = WorkflowRunId(randomUUID())
const info: WorkflowRunInfo = { id, meta }
const limits: WorkerLimits = {
maxConcurrentAgents: this.config.maxConcurrentAgents === 0
? Math.min(16, Math.max(1, availableParallelism() - 2))
: this.config.maxConcurrentAgents,
maxTotalAgents: this.config.maxTotalAgents,
maxTotalAgents,
maxItemsPerCall: this.config.maxItemsPerCall,
syncTimeoutMs: this.config.syncTimeoutMs,
}
@@ -144,7 +176,7 @@ class WorkerWorkflowEngine extends WorkflowService {
meta,
request.parent,
init,
this.config.provider,
subagentProvider,
this.config.disposeGraceMs,
{
phase: (title) => { this.emitWorkflowEvent('workflow/phase', info, title) },
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff. Show more