refactor(agent-loop): simplify message machine

This commit is contained in:
_Kerman
2026-07-24 11:46:06 +08:00
parent 7b7f793ee5
commit aaa42d5844
25 changed files with 1205 additions and 2622 deletions

View File

@@ -1,7 +1,11 @@
/**
* 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.
* 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.
* @module @deepseek-ai/dsh-agent/dispatch
*/
@@ -11,11 +15,6 @@ 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
@@ -30,84 +29,43 @@ export type AgentSubjectEvent = {
}[keyof Events]
/** The event arguments AFTER the injected agent subject. */
type Tail<K extends AgentSubjectEvent> = Params<Events[K]> extends [Agent, ...infer R] ? R : never
type Tail<K extends AgentSubjectEvent> = Events[K] extends (...args: infer P) => unknown
? P extends [Agent, ...infer R] ? R : never
: never
/**
* 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.
* 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.
*/
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]>
export function agentCarrier(agent: Agent): Scoped<Agent> {
return scopeTarget(agent, agent)
}
/**
* Build a dispatcher that couples the agent subject to its scope carrier.
* 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.)
* @param ctx - the context to dispatch through (any context of the app).
* @param agent - the subject agent; also the scope-carrier key.
* @returns the fused dispatcher.
* @param name - the agent-subject event to emit.
* @param rest - the event's arguments after the injected agent.
*/
export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
const carrier: Scoped<Agent> = scopeTarget(agent, 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)
},
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)}`)
}
}
}

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 { agentEvents, assembleContextFor } from './dispatch.ts'
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
export { agentCarrier, assembleContextFor, emitAgentEvent } from './dispatch.ts'
export type { AgentSubjectEvent } from './dispatch.ts'
declare module 'cordis' {
interface Context {

View File

@@ -19,9 +19,6 @@ const install: InvariantInstaller = (ctx, fail) => {
if (previous === status) {
fail(`agent/status repeated ${status} (no-op transition)`)
}
if (previous === 'disposed') {
fail(`agent/status left terminal state disposed → ${status}`)
}
lastStatus.set(agent, status)
}, { global: true })

View File

@@ -49,7 +49,7 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR
})
const disposeRequest = agentCtx.on(
'agent/request',
async (_agent, _turn, _step, _config, _signal, next): Promise<LlmCallConfig> => {
async (_agent, _turn, _step, _signal, next): Promise<LlmCallConfig> => {
const resolved = await next()
const selected = target.assembled
return selected === undefined ? resolved : {

View File

@@ -2,13 +2,29 @@
* Public agent types and live-runtime events. Durable transcript facts and
* turn/step boundaries remain `@deepseek-ai/dsh-session` events.
*
* The agent is a naive message machine over the session log: prompts queue
* (one turn each), steering/context ride the outbox (taken whole at every
* step boundary), and the log re-derives the request history each step — so
* "edit history between steps" needs no dedicated seam. The extension surface
* is deliberately small:
*
* - `agent/prompt-submit` (waterfall): veto/rewrite a claimed prompt.
* - `agent/request` (waterfall): replace the call config per request.
* - `agent/step` (serial): awaited before every request is built — inject
* context, steer, or edit the log here; the request derives after it.
* - `agent/stopping` (serial): the turn is about to close — steer to object.
* - a tool result carrying `concludesTurn` ends the turn at its step (data,
* not a hook): the terminal-tool pattern.
* - `agent/idle` (emit): one per turn close, carrying why it ended. Error
* recovery is a consumer loop: observe an error idle, fix (edit the log,
* wait out a rate limit), then `agent.retry()`.
*
* @module @deepseek-ai/dsh-agent/types
*/
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, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, LlmCallConfig, LlmFailure, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
declare module '@deepseek-ai/dsh-system-prompt' {
@@ -26,147 +42,62 @@ export interface AgentOptions {
model?: string
}
/**
* 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 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.
*/
/** One queued prompt or steering item and its atomic model-facing context. */
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.
*/
/** 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[]
/** Opaque JSON state retained on the durable message but hidden from the model. */
}
/** Options for synthetic context injection. */
export interface InjectOptions {
/** Explicit producer attribution. */
source: MessageSource
/** Opaque durable state omitted from the model projection. */
meta?: JsonValue
}
/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */
export type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'>
/**
* Opaque id assigned to one accepted {@link Agent.send} message; returned by
* `send` and carried on its `agent/inbox/*` events for correlation.
* 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.
*/
export type AgentMessageId = Branded<'AgentMessageId'>
export type AgentStatus = 'idle' | 'running'
/**
* 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' | 'disposed'
/** Model-facing context injected by a listener or atomically attached to one inbox message. */
/** Model-facing context injected by a listener or atomically attached to one inbox item. */
export interface HookContext {
content: ContentBlock[]
source: MessageSource
/**
* 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.
*/
/** `prompt-prefix` bakes this context into its prompt; absent/`separate` records an independent message. */
placement?: 'separate' | 'prompt-prefix'
/** Opaque JSON state retained in the session event but hidden from the model. */
/** Opaque durable state omitted from the model projection. */
meta?: JsonValue
}
/**
* Prompt interception result. `allow.content` replaces the prompt. Each
* `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.
* `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.
*/
export type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
| { kind: 'block'; reason: string }
/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */
export type ContinuationDecision =
| { action: 'stop' }
| { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } }
/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */
export type RequestErrorDecision = { action: 'fail' } | { action: 'retry' }
/** Model-request failure with an optional machine-routable provider code. */
export type RequestError = Error & { code?: string }
/**
* The terminal subset of {@link ContinuationDecision}. A listener on
* `agent/turn-stop` returns this to make the already-composed continuation
* outcome terminal; `undefined` abstains.
* Why a turn ended, reported live on `agent/idle` right after the turn's
* durable `turn/end` and flush. `error` carries the live Error (and, for
* model-request failures, the adapter-normalized facts) so a recovery
* consumer can decide to repair and {@link Agent.retry}.
*/
export type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }>
export type IdleReason =
| { kind: 'completed' }
| { kind: 'aborted' }
| { kind: 'error'; error: Error; failure?: LlmFailure }
/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
@@ -179,104 +110,60 @@ export type AgentCancelCause =
/** Runtime reason carried by the signal that controls one live turn. */
export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' }
/**
* Public agent handle; its concrete implementation is internal to
* `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so
* the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer},
* {@link Agent.inject}) are shared concrete delegates over the single abstract
* {@link Agent.send} primitive; concrete drivers implement `send` once.
*/
export abstract class Agent {
/** Public live-agent handle; driving methods have no contract after disposal. */
export interface Agent {
/** The single identity shared with {@link session}. */
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
readonly id: SessionId
readonly options: AgentOptions
readonly session: Session
readonly status: AgentStatus
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
abstract readonly ctx: Context
readonly ctx: Context
/**
* 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.
* 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.
*/
abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId
send(content: ContentBlock[], options: SendOptions): void
/**
* 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.
* 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}.
*/
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>
steer(content: ContentBlock[], options: SendOptions): void
/**
* 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}.
* 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.
*/
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
return this.send(content, { ...options, target: 'next-turn', wakeup: true })
}
inject(content: ContentBlock[], options: InjectOptions): void
/**
* 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}.
* 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.
*/
steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
return this.send(content, { ...options, target: 'next-step', wakeup: true })
}
cancel(cause?: AgentInterruptReason): void
/**
* 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}.
* Re-open a turn on the current session log without a new prompt — the
* recovery verb. After an `agent/idle` error, a consumer repairs (edits the
* log, waits out a rate limit) and calls this; the machine immediately runs
* another turn over the repaired history. Calling it synchronously from an
* `agent/idle` listener is legal — the machine is already idle there.
* @throws while a turn is running because there is nothing to retry yet.
*/
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
return this.send(content, { ...options, target: 'next-step', wakeup: false })
}
retry(): void
/** Resolve at idle quiescence; disposal waits for machine exit rather than only the status transition. */
whenIdle(): Promise<void>
}
declare module 'cordis' {
@@ -294,7 +181,7 @@ declare module 'cordis' {
*/
'agent/created'(this: Scoped<Agent>, agent: Agent): void
/**
* An agent left the registry; AgentLoop emits this after driver quiescence
* An agent left the registry; AgentLoop emits this after machine 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.
@@ -303,7 +190,7 @@ declare module 'cordis' {
*/
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
/**
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does
* Agent activity changed (`idle` ⇄ `running`). `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).
@@ -312,39 +199,17 @@ declare module 'cordis' {
*/
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
/**
* A detached, frozen item entered the agent's inbox (queued or steering
* FIFO). Source defaults are already applied, so `message` holds the exact
* accepted values. This is the enqueue-time live signal; the durable record
* is the eventual `user/message`/`steering/message`. Injection
* (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.
* @param agent - the agent whose inbox received the item.
* @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).
* 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.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): 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/steering work
* Effective broad cancellation was requested, before queued/outbox work
* is cleared or the active turn is aborted. This observe-only notification
* cannot veto cancellation; listener failures are contained.
* @param agent - the agent whose current work is being cancelled.
@@ -352,14 +217,12 @@ 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: AgentCancelCause): void
// ---- session lifecycle (emit) ----
'agent/cancel-requested'(this: Scoped<Agent>, agent: Agent, cause: AgentInterruptReason): void
/**
* 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
* driver starts.
* machine 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.
@@ -367,29 +230,12 @@ declare module 'cordis' {
*/
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void
// Turn and step boundaries are durable session events, not agent events.
// ---- step/request extension seams (serial + waterfall) ----
/**
* Awaited serial checkpoint before `step/start`; appends land outside the
* pending step and are included when the loop derives request history.
* `signal` cancels listener work.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @param agent - the agent opening the step.
* @param turn - the open turn number.
* @param step - the pending step number.
* @param signal - the turn abort signal.
* @mode serial
*/
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void
// ---- the machine's extension seams ----
/**
* Allow, rewrite, or block one claimed prompt before it becomes a user
* message. Call `next()` for the unchanged default. A listener wrapping a
* downstream `allow` must preserve its `content` and `additionalContexts`
* unless it intentionally replaces them. The signal controls only this turn;
* listeners may cooperate with it but must not retain it to control another
* turn. Steering messages do not dispatch this event; they join an open turn
* at a steering checkpoint.
* message. Call `next()` for the unchanged default, including contexts
* captured with the queued item. The signal controls only this turn;
* listeners may cooperate with it but must not retain it for another turn.
* @param agent - the agent whose turn claimed the message.
* @param content - the claimed message's blocks, as queued.
* @param source - the message's resolved source.
@@ -399,100 +245,64 @@ declare module 'cordis' {
*/
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>
/**
* Replace the frozen call configuration. Model-visible content must use
* logged channels; this seam cannot mutate messages. Injection here joins
* the next request because the current step boundary is already fixed.
* Awaited serial checkpoint before EVERY request of a turn is built (the
* first as well as each post-tools continuation). The single "between
* steps" seam: inject context, steer, or edit the session log here — the
* request's history derives from the log right after this settles.
* @param agent - the agent about to send a request.
* @param turn - the open turn number.
* @param step - the step number about to open.
* @param signal - the turn abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode serial
*/
'agent/step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void
/**
* Replace the frozen call configuration. `await next()` yields the config
* the machine would use (agent options on the first request, the logged
* header afterwards); return a replacement to switch. Model-visible
* content must use logged channels; this seam cannot mutate messages.
* @param agent - the agent making the model call.
* @param turn - the open turn number.
* @param step - the step whose request this is.
* @param config - the config the loop would use (frozen); return a replacement to switch.
* @param signal - the current turn's explicit abort signal; ambient
* initiator identity does not imply liveness or cancellation authority.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
/**
* Compose request-only messages placed before derived history. The frozen
* result is computed once per loop instance, logged on its anchoring request
* header, and reused so the provider prefix remains stable. Interrupted
* composition is discarded. Composition precedes the first `agent/pre-step`
* and request boundary, so listener appends join the current request.
* Changing context belongs in history; contributors should prepend to
* `await next()` to preserve registration order.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @param agent - the agent whose session prefix is being composed.
* @param prefix - the frozen seed; return an extended replacement.
* @param signal - the current turn's explicit abort signal.
* @mode waterfall
*/
'agent/session-prefix'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
/**
* Waterfall: post-process the assembled assistant {@link Message} before
* tool dispatch (validation, content rewriting, …).
* @param agent - the agent that received the step's response.
* @param turn - the open turn number.
* @param step - the step that produced the message.
* @param message - the assistant message as assembled from the stream.
* @param signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
* @mode compose
*/
'agent/step-result'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise<Message>): Promise<Message>
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
/**
* Awaited serial checkpoint after the response, real or synthetic tool
* results, injected context, and steering are durable but before `step/end`.
* A cancelled tool batch reaches this checkpoint with an aborted signal.
* @param agent - the agent whose step is settling.
* @param turn - the open turn number.
* @param step - the open step number.
* @param signal - the turn abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode serial
*/
'agent/post-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void
/**
* Recover a model-request failure after its failed step has closed. `retry`
* opens a new numbered step; `fail` preserves the original request error.
* Call `next()` to delegate to the next recovery listener or the default.
* @param agent - the agent whose request failed.
* @param turn - the open turn number.
* @param step - the failed step number.
* @param error - the original model-request failure.
* @param failure - serializable facts normalized at the final adapter boundary.
* @param priorFailures - immutable failures that already authorized another request in this consecutive sequence.
* @param signal - the turn abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise<RequestErrorDecision>): Promise<RequestErrorDecision>
/**
* Override whether the turn continues. The default continues after tool
* calls or steering and stops otherwise; a continue reason becomes steering.
* @param agent - the agent deciding whether to run another step.
* @param turn - the turn being continued or stopped.
* @param defaultDecision - what the loop would do absent an override.
* @param signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
/**
* Monotonic terminal-stop checkpoint after continuation and steering are
* folded; a stop remains authoritative through turn close and flush:
* steering queued in that window is discarded, while ordinary sends survive.
* @param agent - the agent whose composed continuation outcome may be stopped.
* @param turn - the turn at its terminal-stop checkpoint.
* The turn is about to close: the model owes no response (no live tool
* calls, no fresh steering). Awaited before the boundary commits — a
* listener that objects steers (`agent.steer(...)`) and the machine
* re-reads its inbox: fresh steering runs another step, none closes the
* turn. Data decides, so listener order cannot change the outcome. The
* inverse control (stop a tool loop early) is data too: a tool result
* carrying `concludesTurn` ends the turn at its step.
* @param agent - the agent whose turn is at its stop boundary.
* @param turn - the turn about to close.
* @param signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode serial
*/
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
'agent/stopping'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void
/**
* One turn closed: its `turn/end` and durability flush are already
* committed. `reason` says why — recovery consumers observe an `error`
* reason, repair (edit the log, wait, resummon), and call
* {@link Agent.retry}; UI consumers key turn-done presentation off it.
* Emitted per turn, including cancelled and failed ones.
* @param agent - the agent whose turn closed.
* @param turn - the closed turn number.
* @param reason - why the turn ended, with live error facts when it failed.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/idle'(this: Scoped<Agent>, agent: Agent, turn: number, reason: IdleReason): void
// ---- error notifications (emit) ----
/**
* A step or turn errored. The loop reports a failure here (plus the logger)
* even when the error has no in-turn position for a session `error` event.
* A step or turn errored. The machine reports a failure here (plus the
* logger) even when the error has no in-turn position for a durable record.
* @param agent - the agent whose turn errored.
* @param turn - the turn in which the failure surfaced.
* @param step - the step at which the failure surfaced.