fix(agent-loop): preserve unified send contracts

This commit is contained in:
_Kerman
2026-07-24 13:18:57 +08:00
parent aaa42d5844
commit 7d5c8b12c0
19 changed files with 476 additions and 223 deletions

View File

@@ -156,7 +156,7 @@ export function apply(ctx: Context, config: Config): void {
}
const resolvedTimeZone = formatter.resolvedOptions().timeZone
ctx.on('agent/pre-step', (
ctx.on('agent/step', (
agent: Agent,
turn: number,
step: number,

View File

@@ -1,7 +1,7 @@
/**
* Workspace instruction loader for AGENTS.md-compatible files.
*
* Baseline instructions are frozen into `agent/session-prefix`; successful fs
* Baseline instructions enter durable context before the first request; successful fs
* tool touches reconcile nested, changed, and removed instructions through
* `tools/post-execute` for the next model request. Plugin lifecycle reads use
* the optional `ctx.fs` provider, so providerless products mount it as a no-op.
@@ -11,7 +11,6 @@
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
import { loadBaselineInstructionSet } from './files.ts'
@@ -50,6 +49,7 @@ export function apply(ctx: Context, config: Config): void {
const baselineInstructionStates = new WeakMap<object, Map<string, WorkspaceInstructionChange>>()
const instructionVersions: InstructionVersionCache = new WeakMap()
const pendingVersionUpdates = new Map<ToolExecutionToken, InstructionVersionUpdate[]>()
const baselineLoaded = new WeakSet<object>()
const pendingByParent = new Map<ToolExecutionToken, {
agent: Agent
changes: WorkspaceInstructionChange[]
@@ -60,11 +60,17 @@ export function apply(ctx: Context, config: Config): void {
observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions)
})
ctx.on('agent/session-prefix', async (agent: Agent, _prefix, signal, next): Promise<Message[]> => {
const rest = await next()
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) return rest
ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
if (baselineLoaded.has(agent.session)) return
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) {
baselineLoaded.add(agent.session)
return
}
const fileSystem = ctx.get('fs')
if (fileSystem === undefined) return rest
if (fileSystem === undefined) {
baselineLoaded.add(agent.session)
return
}
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = agent.session.header.cwd ?? process.cwd()
const instructions = await loadBaselineInstructionSet({
@@ -97,8 +103,11 @@ export function apply(ctx: Context, config: Config): void {
})
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
}
if (instructions === undefined || instructions.rendered.text.length === 0) return rest
return [workspaceContextMessage(instructions.rendered.text), ...rest]
if (instructions !== undefined && instructions.rendered.text.length > 0) {
const baselineMessage = workspaceContextMessage(instructions.rendered.text)
agent.inject(baselineMessage.content, { source: { kind: 'plugin', plugin: 'workspace-context' } })
}
baselineLoaded.add(agent.session)
})
ctx.on('tools/post-execute', async (

View File

@@ -14,18 +14,20 @@
* @module dsh-agent-loop/agent
*/
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import { agentCarrier, agentInterruptReasonOf, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
import { Agent, AgentMessageId, agentCarrier, agentInterruptReasonOf, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import type {
Agent,
AgentMessage,
AgentMessageId as AgentMessageIdType,
CancelOptions,
AgentInterruptReason,
AgentOptions,
AgentStatus,
HookContext,
IdleReason,
InjectOptions,
PromptDecision,
SendOptions,
} from '@deepseek-ai/dsh-agent'
@@ -36,16 +38,19 @@ import type {
ContentBlock, GenerateOptions, LlmCallConfig, LlmFailure, Message, MessageSource,
} from '@deepseek-ai/dsh-llm'
import { canonicalHeader, headerEquals, snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { PromptMessageData, Session, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { JsonValue, PromptMessageData, Session, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
/** A prompt waiting for a turn of its own. */
interface QueuedMessage {
id: AgentMessageIdType
content: ContentBlock[]
source: MessageSource
contexts: HookContext[]
wakeup: boolean
meta?: JsonValue
}
/** Input awaiting the next step boundary. */
@@ -53,6 +58,18 @@ type OutboxItem =
| ({ kind: 'steering' } & QueuedMessage)
| { kind: 'context'; context: HookContext }
/** Build one live inbox event payload from an accepted message. */
function inboxMessage(message: QueuedMessage, steering: boolean): AgentMessage {
return {
id: message.id,
content: message.content,
source: message.source,
contexts: message.contexts,
steering,
wakeup: message.wakeup,
}
}
const PROMPT_PREFIX_REQUEST_DELIMITER: ContentBlock = {
type: 'text',
text: '\n\n## My request:\n',
@@ -118,13 +135,13 @@ function withoutToolCalls(message: Message): Message {
* history in, one assistant message out, loop until a reply owes no tool call.
* One `run()` drains the work queue, one turn per unit.
*/
export class ReactLoopAgent implements Agent {
export class ReactLoopAgent extends Agent {
/** Prompts awaiting a turn of their own: one dequeued per turn, FIFO. */
private queued: QueuedMessage[] = []
/** Taken whole at every step boundary; caller-editable until taken (taken = entered the log). */
private outbox: OutboxItem[] = []
/** Whether `run()` is driving a turn right now — the single activity truth. */
/** Whether observers see one running drain interval; queued turns share it. */
private busy = false
/** The active turn's abort owner; rotated per turn, aborted by {@link cancel}. */
private turnAbort: AbortController | undefined
@@ -152,13 +169,14 @@ export class ReactLoopAgent implements Agent {
public readonly options: AgentOptions,
public readonly session: Session,
) {
super()
this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
// The scope is keyed by this agent — an opaque identity, fine mid-construction.
this.scope = createScope(loopCtx, this)
this.ctx = this.scope.ctx.extend({ agent: this })
}
/** Pure activity: whether a run is driving right now. */
/** Last activity state published to observers. */
get status(): AgentStatus {
return this.busy ? 'running' : 'idle'
}
@@ -176,47 +194,40 @@ export class ReactLoopAgent implements Agent {
// Public driving verbs.
// -------------------------------------------------------------------------
/** Queue a prompt: one turn of its own, FIFO. */
send(content: ContentBlock[], options: SendOptions): void {
const accepted = this.accept({ content, source: options.source, contexts: options.contexts ?? [] })
this.queued.push(accepted)
emitAgentEvent(this.loopCtx, this, 'agent/queued', accepted.content, {
source: accepted.source,
contexts: accepted.contexts,
steering: false,
})
this.kick()
}
/** Accept and route one unified send item. */
send(content: ContentBlock[], options: SendOptions = {}): AgentMessageIdType {
const id = AgentMessageId(randomUUID())
const target = options.target ?? 'next-turn'
const wakeup = options.wakeup ?? true
if (target === 'next-step' && !wakeup) {
this.injectContext(content, options)
return id
}
/** Steer the running turn: taken at the next step boundary. With no turn running, falls back to {@link send}. */
steer(content: ContentBlock[], options: SendOptions): void {
// `busy` (a turn is actually running), not status: status stays `running`
// across chained turns and through the agent/idle report, where steering
// has no live turn to join and must become a prompt of its own.
if (!this.busy) { this.send(content, options); return }
const accepted = this.accept({ content, source: options.source, contexts: options.contexts ?? [] })
this.outbox.push({ kind: 'steering', ...accepted })
emitAgentEvent(this.loopCtx, this, 'agent/queued', accepted.content, {
source: accepted.source,
contexts: accepted.contexts,
steering: true,
})
}
/**
* Stage model-facing context without running the model: it rides along with
* whatever runs next (the next step of the running turn, or the next turn).
* While the agent is idle the context is committed immediately as a one-shot
* turn. Appending IS the durable write — persistence drains eagerly on
* every append and owns the write chain end to end.
*/
inject(content: ContentBlock[], options: InjectOptions): void {
const context = this.accept({
const steering = target === 'next-step' && this.turnAbort !== undefined
const accepted = this.accept({
id,
content,
source: options.source,
source: options.source ?? { kind: 'user' },
contexts: options.contexts ?? [],
wakeup,
...options.meta === undefined ? {} : { meta: options.meta },
})
if (this.busy) {
if (steering) this.outbox.push({ kind: 'steering', ...accepted })
else this.queued.push(accepted)
emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', inboxMessage(accepted, steering))
if (!steering && wakeup) this.kick()
return id
}
/** Stage non-waking context at the next step boundary, or in an idle one-shot turn. */
private injectContext(content: ContentBlock[], options: SendOptions): void {
const context = this.accept({
content,
source: options.source ?? { kind: 'plugin', plugin: '' },
...options.meta === undefined ? {} : { meta: options.meta },
})
if (this.turnAbort !== undefined) {
this.outbox.push({ kind: 'context', context })
return
}
@@ -227,7 +238,7 @@ export class ReactLoopAgent implements Agent {
try {
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source: context.source } })
opened = true
this.session.append('context/message', context, { surfaceOp: 'append' })
this.session.append('user/message', context, { surfaceOp: 'append' })
} finally {
// Close only a turn whose start committed; a pre-commit veto escapes.
if (opened) this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
@@ -241,16 +252,23 @@ export class ReactLoopAgent implements Agent {
* `cancel({kind:'disposed'})` + await {@link done} + {@link scope} dispose,
* all owned by the factory.
*/
cancel(cause: AgentInterruptReason = { kind: 'user' }): void {
cancel(cause: AgentInterruptReason, options: CancelOptions = {}): void {
if (this.turnAbort !== undefined || this.queued.length > 0 || this.outbox.length > 0) {
// Observe-only: coordination consumers update their state before the
// inboxes clear; listener failures are contained by the dispatcher.
emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause)
if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause)
}
if (!options.keepInbox) {
const steering = this.outbox.flatMap(item => item.kind === 'steering' ? [item] : [])
const discarded = [
...this.queued.map(message => inboxMessage(message, false)),
...steering.map(message => inboxMessage(message, true)),
]
// Clear before abort observers run: replacement work belongs to the next turn.
this.queued.length = 0
this.outbox.length = 0
if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded)
}
// Clear before abort observers run: a replacement enqueued by an observer
// belongs to the next turn.
this.queued.length = 0
this.outbox.length = 0
this.turnAbort?.abort(Object.freeze({ kind: cause.kind }))
}
@@ -261,15 +279,15 @@ export class ReactLoopAgent implements Agent {
* @throws while a turn is running — there is nothing to retry yet.
*/
retry(): void {
if (this.busy) throw new Error(`agent "${this.id}" cannot retry while busy`)
if (this.turnAbort !== undefined) throw new Error(`agent "${this.id}" cannot retry while busy`)
this.start()
}
/** Resolve at idle quiescence: no run driving and no prompt waiting. */
/** Resolve at idle quiescence: no run driving and no waking prompt waiting. */
async whenIdle(): Promise<void> {
// `done` is replaced per run, so re-reading it each lap follows chained
// turns; a run failure still counts as quiescence for the waiter.
while (this.busy || this.queued.length > 0) await this.done.catch(() => undefined)
while (this.turnAbort !== undefined || this.queued.some(message => message.wakeup)) await this.done.catch(() => undefined)
}
// -------------------------------------------------------------------------
@@ -278,18 +296,25 @@ export class ReactLoopAgent implements Agent {
/** Claim the next queued prompt and open a run on it, when nothing is driving. */
private kick(): void {
if (this.busy) return
if (this.turnAbort !== undefined || !this.queued.some(message => message.wakeup)) return
const message = this.queued.shift()
if (message !== undefined) this.start(message)
if (message !== undefined) {
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(message, false))
this.start(message)
}
}
/** Open one `run()` — on a claimed prompt, or promptless for a retry. The caller has checked `busy`. */
/** Open one `run()` — on a claimed prompt, or promptless for a retry. */
private start(prompt?: QueuedMessage): void {
this.busy = true
emitAgentEvent(this.loopCtx, this, 'agent/status', 'running')
const controller = new AbortController()
this.turnAbort = controller
if (!this.busy) {
this.busy = true
emitAgentEvent(this.loopCtx, this, 'agent/status', 'running')
}
// The whole run inherits this agent as its process-local initiator so
// tools, the llm service, and nested factories can attribute their work.
this.done = this.loopCtx.agents.withInitiator(this, () => this.run(prompt))
this.done = this.loopCtx.agents.withInitiator(this, () => this.run(prompt, controller))
}
/**
@@ -300,9 +325,7 @@ export class ReactLoopAgent implements Agent {
* boundaries and runs the idle tail, which opens the next run while work
* remains.
*/
private async run(prompt?: QueuedMessage): Promise<void> {
const controller = new AbortController()
this.turnAbort = controller
private async run(prompt: QueuedMessage | undefined, controller: AbortController): Promise<void> {
const signal = controller.signal
const turn = ++this.lastTurn
let idle: IdleReason = { kind: 'completed' }
@@ -342,7 +365,10 @@ export class ReactLoopAgent implements Agent {
prompt.source,
decision.additionalContexts ?? [],
)
this.session.append('user/message', prepared.data, { surfaceOp: 'append' })
this.session.append('user/message', {
...prepared.data,
...prompt.meta === undefined ? {} : { meta: prompt.meta },
}, { surfaceOp: 'append' })
for (const context of prepared.separateContexts) {
this.outbox.push({ kind: 'context', context: this.accept(context) })
}
@@ -369,7 +395,7 @@ export class ReactLoopAgent implements Agent {
this.closeTurn(turn, step, reason)
} catch (error: unknown) {
// A rejected boundary append (a pre-commit validation veto) must not
// kill the machine or leave `busy` stuck: report and move on — the
// kill the machine or strand its running interval: report and move on — the
// idle tail below still runs and the next turn still opens.
const err = toError(error)
this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(err)}`)
@@ -544,7 +570,7 @@ export class ReactLoopAgent implements Agent {
for (const item of this.outbox.splice(0)) {
if (item.kind === 'context') {
const { content, source, meta } = item.context
this.session.append('context/message', {
this.session.append('user/message', {
content,
source,
...meta === undefined ? {} : { meta },
@@ -552,11 +578,16 @@ export class ReactLoopAgent implements Agent {
continue
}
steered = true
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(item, true))
const prepared = preparePromptMessage(item.content, item.source, item.contexts)
this.session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' })
this.session.append('steering/message', {
turn,
...prepared.data,
...item.meta === undefined ? {} : { meta: item.meta },
}, { surfaceOp: 'append' })
for (const context of prepared.separateContexts) {
const { content, source, meta } = context
this.session.append('context/message', {
this.session.append('user/message', {
content,
source,
...meta === undefined ? {} : { meta },
@@ -607,31 +638,30 @@ export class ReactLoopAgent implements Agent {
}
/**
* The turn boundary's tail (naive `idle()`): the machine is no longer busy,
* The turn boundary's tail (naive `idle()`): no turn owner remains,
* the idle report fires (a listener may synchronously `retry()` or `send()`
* here — both are legal now), leftover steering becomes queued prompts, and
* the next run opens while the queue is non-empty; otherwise the machine
* parks.
*/
private idle(turn: number, idle: IdleReason): void {
this.busy = false
// Status mirrors busy faithfully: chained turns pulse idle → running,
// which is honest — a listener really can act in this window.
emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle')
// Requeue BEFORE the idle report so earlier-arrived steering keeps its
// FIFO position ahead of anything a listener send()s synchronously.
for (const item of this.outbox.splice(0)) {
if (item.kind === 'steering') this.queued.push({
content: item.content,
source: item.source,
contexts: item.contexts,
})
else this.outbox.push(item)
if (item.kind === 'context') {
this.outbox.push(item)
continue
}
const { kind: _kind, ...message } = item
this.queued.push(message)
}
emitAgentEvent(this.loopCtx, this, 'agent/idle', turn, idle)
// A synchronous idle listener may retry()/send(), flipping busy back.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.busy) return // a listener already re-opened
if (this.queued.length > 0) this.kick()
// A synchronous idle listener may retry()/send(), installing a new owner.
if (this.turnAbort !== undefined) return // a listener already re-opened
if (this.queued.some(message => message.wakeup)) this.kick()
else {
this.busy = false
emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle')
}
}
}

View File

@@ -71,7 +71,7 @@ describe('Agent.cancel()', () => {
})
send(agent, 'drop me')
agent.cancel()
agent.cancel({ kind: 'user' })
await new Promise(resolve => setTimeout(resolve, 30))
agent.cancel({ kind: 'parent' })
@@ -403,7 +403,7 @@ describe('Agent.cancel()', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
agent.cancel()
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'aborted' }])

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'

View File

@@ -60,7 +60,7 @@ The handle every plugin programs against:
- `agent.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
- `agent.steer(content, options?)` — the `next-step`/wakeup preset: while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. 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.
- `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) 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)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event.
- `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
- `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
- `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`

View File

@@ -1,11 +1,7 @@
/**
* Agent-scoped dispatch helpers. An agent-subject event travels with the
* agent's scope carrier as `thisArg` (so scoped listeners filter to their own
* agent) and the agent itself as the first argument. Composable seams are
* plain `ctx.waterfall(carrier, name, agent, …, next)` calls at the machine's
* call sites — concrete event names type-check against the real Cordis
* overloads, so no generic wrapper (and none of its casts) is needed. The one
* helper here is {@link emitAgentEvent}: a contained fire-and-forget emit.
* Agent-scoped dispatch and prompt assembly helpers. Ordinary events use the
* fused dispatcher so subject and scope key cannot diverge; registry lifecycle
* code instead captures one stable carrier for both edges.
* @module @deepseek-ai/dsh-agent/dispatch
*/
@@ -15,6 +11,11 @@ import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
import type { Agent } from './types.ts'
/** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */
type Params<F> = F extends (...args: infer P) => unknown ? P : never
/** Extract the return type from an event handler type. */
type Return<F> = F extends (...args: never[]) => infer R ? R : never
/**
* The event names whose subject is an agent: handler parameters start with an
* `Agent` AND the handler declares a `Scoped<Agent>` `this` (the scope-carrier
@@ -29,46 +30,102 @@ export type AgentSubjectEvent = {
}[keyof Events]
/** The event arguments AFTER the injected agent subject. */
type Tail<K extends AgentSubjectEvent> = Events[K] extends (...args: infer P) => unknown
? P extends [Agent, ...infer R] ? R : never
: never
type Tail<K extends AgentSubjectEvent> = Params<Events[K]> extends [Agent, ...infer R] ? R : never
/**
* The scope carrier for an agent-subject dispatch: the agent fused as both
* the carrier key and the event subject, so the two cannot diverge. Pass it
* as the `thisArg` of `ctx.serial` / `ctx.waterfall` for agent events.
* @param agent - the subject agent.
* @returns the fused carrier.
* The fused dispatcher {@link agentEvents} returns: each method dispatches the
* named agent-subject event with the agent's scope carrier as `thisArg` and
* the agent itself injected as the first event argument.
*/
export interface AgentEventDispatch {
/**
* Fire-and-forget notification in the agent's scope. Every listener is
* invoked; synchronous throws and returned-promise rejections are logged and
* contained per listener, so a notification cannot veto lifecycle progress
* or starve a later observer.
* @param name - the agent-subject event to emit.
* @param rest - the event's arguments after the injected agent.
*/
emit<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): void
/**
* Awaited in-order dispatch (Cordis `serial`) in the agent's scope.
* @param name - the agent-subject event to dispatch.
* @param rest - the event's arguments after the injected agent.
* @returns the serial chain's result (the first bail value, if any).
*/
serial<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Promise<Awaited<Return<Events[K]>>>
/**
* Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The
* declared event parameters already end with the `next` callback, so `rest`
* is exactly the event's arguments after the injected agent — the final
* element being the innermost `next` (the default the listener chain wraps).
* @param name - the agent-subject event to dispatch.
* @param rest - the event's arguments after the injected agent.
* @returns the waterfall's composed result.
*/
waterfall<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Return<Events[K]>
}
/** Return the fused scope carrier for one agent subject. */
export function agentCarrier(agent: Agent): Scoped<Agent> {
return scopeTarget(agent, agent)
}
/**
* Fire-and-forget notification in the agent's scope. Every listener is
* invoked; synchronous throws and returned-promise rejections are logged and
* contained per listener, so a notification cannot veto lifecycle progress or
* starve a later observer. (Raw Cordis `emit` maps callbacks unguarded — one
* synchronous throw would starve the rest and escape into the caller.)
* Build a dispatcher that couples the agent subject to its scope carrier.
* @param ctx - the context to dispatch through (any context of the app).
* @param agent - the subject agent; also the scope-carrier key.
* @param name - the agent-subject event to emit.
* @param rest - the event's arguments after the injected agent.
* @returns the fused dispatcher.
*/
export function emitAgentEvent<K extends AgentSubjectEvent>(ctx: Context, agent: Agent, name: K, ...rest: Tail<K>): void {
const args: unknown[] = [agentCarrier(agent), name, agent, ...rest]
for (const callback of ctx.events.dispatch('emit', args)) {
try {
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`)
})
} catch (error: unknown) {
ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`)
}
export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
const carrier = agentCarrier(agent)
// The ordinary dispatch methods forward through Cordis' variadic mixins. The
// fused (carrier, name, agent, ...rest) tuple is provably a valid argument
// list for the matching thisArg overload, but TypeScript cannot relate the
// generic Tail<K> spread back to that overload's conditional parameter
// tuple — hence one contained, shape-preserving cast per method.
return {
emit(name, ...rest) {
// Cordis emit invokes callbacks through Array.map: one synchronous throw
// starves later listeners, and returned promises are discarded. Agent
// notifications are non-vetoing, so resolve the same filtered callback
// set ourselves and contain both failure modes independently.
const args: unknown[] = [carrier, name, agent, ...rest]
const callbacks = ctx.events.dispatch('emit', args)
for (const callback of callbacks) {
try {
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`)
})
} catch (error: unknown) {
ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`)
}
}
},
async serial(name, ...rest) {
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
const serial = ctx.serial as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => Promise<never>
return await serial(carrier, name, agent, ...rest)
},
waterfall(name, ...rest) {
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
const waterfall = ctx.waterfall as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => never
return waterfall(carrier, name, agent, ...rest)
},
}
}
/** Emit one contained agent notification without allocating a retained dispatcher. */
export function emitAgentEvent<K extends AgentSubjectEvent>(
ctx: Context,
agent: Agent,
name: K,
...rest: Tail<K>
): void {
agentEvents(ctx, agent).emit(name, ...rest)
}
/**
* Build the prompt assembly context with agent and scope set together, so
* agent-scoped prompt and tool contributions cannot be silently omitted.

View File

@@ -17,8 +17,8 @@ import type { Agent, AgentOptions } from './types.ts'
export * from './types.ts'
export { agentInterruptReasonOf } from './cancellation.ts'
export * from './llm-target.ts'
export { agentCarrier, assembleContextFor, emitAgentEvent } from './dispatch.ts'
export type { AgentSubjectEvent } from './dispatch.ts'
export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
declare module 'cordis' {
interface Context {

View File

@@ -23,6 +23,7 @@
*/
import type { Context } from 'cordis'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, LlmCallConfig, LlmFailure, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session'
@@ -42,47 +43,125 @@ export interface AgentOptions {
model?: string
}
/** One queued prompt or steering item and its atomic model-facing context. */
export interface SendOptions {
/** Explicit producer attribution; callers may not inherit human authority by omission. */
source: MessageSource
/** Context snapshotted with this item and admitted at the same boundary. */
contexts?: HookContext[]
}
/**
* Which inbox queue a {@link Agent.send} item joins:
* - `next-turn` — the item becomes its own turn, claimed at a turn boundary.
* - `next-step` — the item joins the active turn between steps as steering,
* or, when no turn is active, is promoted per its `wakeup` flag.
*/
export type SendTarget = 'next-turn' | 'next-step'
/** Options for synthetic context injection. */
export interface InjectOptions {
/** Explicit producer attribution. */
source: MessageSource
/** Opaque durable state omitted from the model projection. */
/**
* Options for the unified {@link Agent.send} primitive over the
* (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup}
* (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and
* {@link Agent.inject} (`next-step`/no-wakeup).
*
* 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 {
/** Queue the item joins; defaults to `next-turn`. */
target?: SendTarget
/**
* Whether this item makes the model run: wake a parked driver (`next-turn`)
* or force a continuation step (`next-step` while running). Defaults to
* `true`. A `false` `next-turn` item queues without waking; a `false`
* `next-step` item attaches durable context without forcing another step
* (the injection preset).
*/
wakeup?: boolean
source?: MessageSource
/**
* Model-facing contexts captured with this inbox item. A queued prompt exposes
* them through the default `agent/prompt-submit` allow decision, while steering
* records them directly at its next checkpoint.
*/
contexts?: HookContext[]
/** Opaque JSON state retained on the durable message but hidden from the model. */
meta?: JsonValue
}
/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */
export type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'>
/**
* An agent's ACTIVITY state, emitted on every transition as `agent/status`:
* `idle` (parked, waiting for queued work) or `running` (the machine is
* draining work). Lifecycle is a separate axis: an agent leaving its host is
* announced by `agent/disposed` and observable as `ctx.agents.get(id)` no
* longer returning it — not as a status value.
* Opaque id assigned to one accepted {@link Agent.send} message; returned by
* `send` and carried on its `agent/inbox/*` events for correlation.
*/
export type AgentMessageId = Branded<'AgentMessageId'>
/**
* Brand a string as an {@link AgentMessageId}.
* @param id - the generated message id.
* @returns the same string, branded; no validation is performed.
*/
export function AgentMessageId(id: string): AgentMessageId {
return id as AgentMessageId
}
/**
* One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live
* events. `id` is the value `send` returned to the caller, stable across this
* message's enqueue, dequeue, and discard events. Source defaults are already
* applied, so these are the exact values the item was accepted with. `steering`
* is true for a `next-step` item drained between steps; a `next-turn` item is
* claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is
* durable model-hidden state that lands on the eventual `user/message`/
* `steering/message`, not live-event routing data.
*/
export interface AgentMessage {
/** The id `send` returned for this message. */
id: AgentMessageId
content: ContentBlock[]
source: MessageSource
contexts: HookContext[]
/** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */
steering: boolean
/** Whether the item is marked to wake the driver or force a continuation. */
wakeup: boolean
}
/** Options for {@link Agent.cancel}. */
export interface CancelOptions {
/**
* Preserve queued and steering inbox items instead of discarding them. The
* active turn is still aborted, but un-started and pending work survives for a
* later turn and no `agent/inbox/discard` fires.
*/
keepInbox?: boolean
}
/**
* An agent's lifecycle state, emitted on every transition as `agent/status`:
* `idle` (parked, waiting for queued work), `running` (the driver is draining
* work and may be closing or checkpointing a turn), `disposed` (terminal — no
* transition leaves it, and `send`/`followup`/`steer`/`inject` throw).
*/
export type AgentStatus = 'idle' | 'running'
/** Model-facing context injected by a listener or atomically attached to one inbox item. */
/** Model-facing context injected by a listener or atomically attached to one inbox message. */
export interface HookContext {
content: ContentBlock[]
source: MessageSource
/** `prompt-prefix` bakes this context into its prompt; absent/`separate` records an independent message. */
/**
* Model placement. Absent or `separate` records an independent injected
* `user/message`; `prompt-prefix` prepends this context and a stable
* request delimiter to the same user-role message as its attached prompt.
*/
placement?: 'separate' | 'prompt-prefix'
/** Opaque durable state omitted from the model projection. */
/** Opaque JSON state retained in the session event but hidden from the model. */
meta?: JsonValue
}
/**
* Prompt interception result. `allow.content` replaces the prompt. Each
* `additionalContexts` entry follows its declared placement. `block` records
* a durable `prompt/blocked` and ends the claimed prompt's zero-step turn as
* rejected. A listener wrapping `next()` preserves downstream fields unless
* it intentionally replaces them.
* `additionalContexts` entry follows its declared placement: separate context
* message by default, or a prefix inside the prompt's user-role message.
* `block` records a durable `prompt/blocked` and ends the claimed prompt's
* zero-step turn as rejected. An `allow` returned by a listener is
* authoritative: a listener wrapping `next()` preserves downstream `content`
* and `additionalContexts` unless it intentionally replaces them.
*/
export type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
@@ -110,47 +189,98 @@ export type AgentCancelCause =
/** Runtime reason carried by the signal that controls one live turn. */
export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' }
/** Public live-agent handle; driving methods have no contract after disposal. */
export interface Agent {
/** Public live-agent handle with aliases over the unified delivery primitive. */
export abstract class Agent {
/** The single identity shared with {@link session}. */
readonly id: SessionId
readonly options: AgentOptions
readonly session: Session
readonly status: AgentStatus
abstract readonly id: SessionId
/** The provider route and model this agent's requests use. */
abstract readonly options: AgentOptions
/** The live session this agent drives; its log is the durable source of truth. */
abstract readonly session: Session
/** The current lifecycle state, mirrored on every `agent/status` transition. */
abstract readonly status: AgentStatus
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
readonly ctx: Context
abstract readonly ctx: Context
/**
* Queue one detached, frozen lossless-JSON prompt. Each claimed prompt is
* the sole ordinary message in its FIFO-ordered turn; the next claimed
* prompt waits for that turn's checkpoint.
* Invalid input throws synchronously before notification or enqueue.
* The unified delivery primitive over the (`target` × `wakeup`) matrix.
* Detaches, validates, and freezes one lossless-JSON item, then routes it:
*
* - `next-turn` (default) queues an item that becomes the sole ordinary
* message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a
* parked driver, while `wakeup:false` queues without waking.
* - `next-step` with `wakeup:true` submits steering into the active turn
* (idle falls back to a woken `next-turn`).
* - `next-step` with `wakeup:false` injects durable model-facing context
* without running the model: an open turn joins at the current log position
* (deferred behind an executing tool batch until it settles), and an idle
* inject records a one-shot turn with its own durability checkpoint.
*
* Attached contexts share the same snapshot and ownership boundary. Invalid
* input throws synchronously before any notification, enqueue, or append.
* @param content - the model-facing content blocks to deliver.
* @param options - target queue, wakeup decision, source, contexts, and meta.
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
*/
send(content: ContentBlock[], options: SendOptions): void
abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId
/**
* Submit steering while the agent is `running`: it enters the outbox and is
* taken whole at the next step boundary, before the next request. Steering
* left over when the turn closes queues for a turn of its own. When idle,
* delegates to {@link send}.
* Clear queued and steering work — unless `keepInbox` — and abort the active
* turn. An effective call first emits `agent/cancel-requested` with the
* resolved typed cause. The first cause wins for the active turn, and
* `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause
* means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm
* later work. The active turn snapshots and freezes the cause.
* @param cause - the stable caller intent carried by the current turn signal.
* @param options - cancellation options; `keepInbox` preserves pending work.
*/
steer(content: ContentBlock[], options: SendOptions): void
abstract cancel(cause: AgentCancelCause, options?: CancelOptions): void
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
abstract whenIdle(): Promise<void>
/**
* Stage detached model-facing context without running the model: it enters
* the outbox and rides along with whatever runs next — the next step of the
* running turn (never between a tool-call batch and its results), or the
* next turn when idle.
* Queue an ordinary follow-up turn and wake the driver — the
* `next-turn`/wakeup preset of {@link send}. The item becomes the sole
* ordinary message of its own turn.
* @param content - the prompt content blocks.
* @param options - source and attached contexts.
* @returns the accepted message's {@link AgentMessageId}.
*/
inject(content: ContentBlock[], options: InjectOptions): void
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
return this.send(content, { ...options, target: 'next-turn', wakeup: true })
}
/**
* Clear all queued and outbox work and abort the active turn. An effective
* call first emits `agent/cancel-requested` with the resolved typed cause;
* the first cause wins for the active turn. Omission means `{ kind: 'user' }`.
* Idle cancellation is a no-op and does not arm later work.
* Submit steering into the running turn — the `next-step`/wakeup preset of
* {@link send}. An open turn records it at the next steering checkpoint before
* a request or continuation decision; policy may stop before another step.
* After turn close and its checkpoint, any remainder is queued for a later
* turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it.
* Idle steering falls back to a woken follow-up turn.
* @param content - the steering content blocks.
* @param options - source and attached contexts.
* @returns the accepted message's {@link AgentMessageId}.
*/
cancel(cause?: AgentInterruptReason): void
steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
return this.send(content, { ...options, target: 'next-step', wakeup: true })
}
/**
* Append detached model-facing context without running the model — the
* `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins
* at the current log position unless the current tool batch is executing;
* then it waits FIFO until that batch settles and drains before turn close
* even when interrupted. Idle injection uses a one-shot turn and durability
* checkpoint. Disposal awaits idle checkpoints; flush failures report through
* `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`.
* @param content - the injected context content blocks.
* @param options - source and durable model-hidden meta.
* @returns the accepted message's {@link AgentMessageId}.
*/
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
return this.send(content, { ...options, target: 'next-step', wakeup: false })
}
/**
* Re-open a turn on the current session log without a new prompt — the
@@ -160,10 +290,7 @@ export interface Agent {
* `agent/idle` listener is legal — the machine is already idle there.
* @throws while a turn is running because there is nothing to retry yet.
*/
retry(): void
/** Resolve at idle quiescence; disposal waits for machine exit rather than only the status transition. */
whenIdle(): Promise<void>
abstract retry(): void
}
declare module 'cordis' {
@@ -181,7 +308,7 @@ declare module 'cordis' {
*/
'agent/created'(this: Scoped<Agent>, agent: Agent): void
/**
* An agent left the registry; AgentLoop emits this after machine quiescence
* An agent left the registry; AgentLoop emits this after driver quiescence
* but before session detachment and scoped-registration unwind. Custom
* registry users own their driver-ordering contract.
* @param agent - the exact agent removed from the registry.
@@ -190,7 +317,7 @@ declare module 'cordis' {
*/
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
/**
* Agent activity changed (`idle` ⇄ `running`). `send()` does
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does
* not enter `running` synchronously; drive lifecycle from this event.
* @param agent - the agent whose status flipped.
* @param status - the status just entered (the transition's destination).
@@ -199,15 +326,33 @@ declare module 'cordis' {
*/
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
/**
* Detached, frozen content entered the agent's inbox (prompt queue or
* steering outbox). These are the exact values retained for the log.
* @param agent - the agent whose inbox received the message.
* @param content - the accepted content blocks retained by the inbox.
* @param info - the accepted source, contexts, and steering classification.
* A frozen item entered the queued or steering inbox.
* @param agent - the owning agent.
* @param message - accepted routing data and correlation identity.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void
'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void
/**
* The driver claimed one item out of the inbox: a queued item at a turn
* boundary, or steering drained between steps. Fires after the item leaves
* its FIFO and before it becomes a durable message.
* @param agent - the agent whose inbox item was claimed.
* @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void
/**
* `cancel()` (without `keepInbox`) dropped pending inbox items without
* delivering them. Fires once per effective clearing call with every
* discarded item, after `agent/cancel-requested` and before the abort.
* @param agent - the agent whose inbox was cleared.
* @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, messages: AgentMessage[]): void
/**
* Effective broad cancellation was requested, before queued/outbox work
* is cleared or the active turn is aborted. This observe-only notification
@@ -217,12 +362,14 @@ declare module 'cordis' {
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/cancel-requested'(this: Scoped<Agent>, agent: Agent, cause: AgentInterruptReason): void
'agent/cancel-requested'(this: Scoped<Agent>, agent: Agent, cause: AgentCancelCause): void
// ---- session lifecycle (emit) ----
/**
* The session lifecycle began, once before the first turn. Use
* `agent.inject()` to seed model-facing context. This is a notification, not
* a veto; disposal requested by a lifecycle owner is rechecked before the
* machine starts.
* driver starts.
* @param agent - the agent whose session lifecycle began.
* @param source - why the session started (fresh startup, resume, …).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.

View File

@@ -82,6 +82,8 @@ export interface CreateSessionOptions {
*/
export interface TurnTriggerMap {
message: { kind: 'message'; source: MessageSource }
/** Recovery turn reopened over the repaired current session log. */
retry: { kind: 'retry' }
/**
* An out-of-band context injection (`agent.inject()`) made while the agent
* was idle. The loop wraps the injected `user/message` (a non-`user` source,

View File

@@ -1415,12 +1415,15 @@ export class ToolRegistry extends Service {
content: result.content,
...result.meta !== undefined ? { meta: result.meta } : {},
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
...result.concludesTurn === true ? { concludesTurn: true as const } : {},
}
if (result.isError) {
return materializePresentation({ isError: true as const, error: result.error, ...presentation })
}
const detached = materializePresentation({ isError: false as const, ...presentation })
const detached = materializePresentation({
isError: false as const,
...presentation,
...result.concludesTurn === true ? { concludesTurn: true as const } : {},
})
return deepFreeze({ ...detached, value: result.value })
}
}

View File

@@ -106,7 +106,7 @@ export function apply(ctx: Context): void {
/** 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
if (ctx.agents.get(state.agent.id) !== state.agent) return undefined
return ctx.goals.get(state.agent)
}
@@ -297,10 +297,6 @@ export function apply(ctx: Context): void {
})
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)

View File

@@ -346,7 +346,7 @@ export class GoalService extends Service {
/** 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') {
if (this.ctx.agents.get(agent.id) !== agent) {
throw new GoalError(`agent "${agent.id}" is not live in this registry`, 'GOAL_AGENT_NOT_LIVE')
}
}

View File

@@ -455,7 +455,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: { sessionId },
}))
}
agent.cancel()
agent.cancel({ kind: 'user' })
return Promise.resolve(ok(request, { accepted: true as const }))
},
},
@@ -542,7 +542,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
queue.push(frame({ type: 'host/session-removed', sessionId: session.id }))
}),
ctx.on('agent/status', (agent: Agent, status: AgentStatus) => {
if (status === 'disposed') return
queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' }))
}),
ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: Error) => {

View File

@@ -52,8 +52,8 @@ function abortedBeforeDispatchResult(): ToolExecutionResult {
/**
* Install semantic checkpoint listeners. Loop-built model calls checkpoint the
* logged request before adapter dispatch; top-level tool calls checkpoint their
* recorded call before the tool body; post-step checkpoints retain the complete
* response/result batch. Nested tool dispatches reuse the durable outer call.
* recorded call before the tool body; the next request boundary checkpoints
* the preceding response/result batch. Nested tool dispatches reuse the durable outer call.
*
* Checkpoint failures are fail-closed at the model and tool side-effect
* boundaries: the downstream adapter or tool body is not invoked.
@@ -74,5 +74,7 @@ export function apply(ctx: Context): void {
return next()
})
ctx.on('agent/post-step', (agent): Promise<void> => ctx.sessions.flush(agent.session))
// Before each request, persist everything committed by the preceding step;
// the first step's call is an intentional no-op beyond any prompt intake.
ctx.on('agent/step', (agent): Promise<void> => ctx.sessions.flush(agent.session))
}

View File

@@ -1,11 +1,12 @@
/**
* Session-prefix skill catalog and model-facing `skill` loader tool.
* Durable session skill catalog and model-facing `skill` loader tool.
*
* @module @deepseek-ai/dsh-tool-skill
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { assertNever, type Message } from '@deepseek-ai/dsh-llm'
import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
@@ -115,12 +116,19 @@ export function apply(ctx: Context, config: Config = {}): void {
// Register after the tool so reverse teardown removes guidance first. Exact definition
// identity prevents a scoped shadow merely named `skill` from inheriting this catalog.
ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise<Message[]> => {
if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) return await next()
const catalogLoaded = new WeakSet<object>()
ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
if (catalogLoaded.has(agent.session)) return
if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) {
catalogLoaded.add(agent.session)
return
}
const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal })
const rest = await next()
if (skills.length === 0) return rest
return [renderCatalogMessage(skills, catalogDescriptionMaxLength), ...rest]
if (skills.length > 0) {
const catalog = renderCatalogMessage(skills, catalogDescriptionMaxLength)
agent.inject(catalog.content, { source: { kind: 'plugin', plugin: 'dsh-tool-skill' } })
}
catalogLoaded.add(agent.session)
})
}

View File

@@ -2519,7 +2519,7 @@ export function createTuiChat(
}
const dispatchMessage = (content: ContentBlock[], contexts: HookContext[]): void => {
if (agent.status === 'disposed') {
if (disposed) {
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
} else if (agent.status === 'running') {
agent.steer(content, { source: { kind: 'user' }, contexts })

View File

@@ -235,8 +235,8 @@ export class ApprovalService extends Service {
})
})
// Visibility layer 2: the boundary narrator. pre-step runs after prompt
// assembly but before the request history is derived, so the notice is
// Visibility layer 2: the boundary narrator. agent/step runs before the
// request history is derived, so the notice is
// seen by THIS step's request: idle-time flip-flops coalesce at the
// turn's first step (net-zero → nothing), and a mid-turn switch is
// narrated no later than the next step. What each session was last told
@@ -246,7 +246,7 @@ export class ApprovalService extends Service {
// switch by the user; otherwise the configured default moved under the
// session (operator/config).
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
ctx.on('agent/pre-step', (agent) => {
ctx.on('agent/step', (agent) => {
const session = agent.session
const events = session.events
let overrideIndex = -1