refactor(agent-loop): simplify message machine
This commit is contained in:
@@ -21,27 +21,6 @@ const install: InvariantInstaller = (ctx, fail) => {
|
||||
}
|
||||
lastStatus.set(agent, status)
|
||||
}, { global: true })
|
||||
|
||||
// Inbox FIFO conservation: an item leaves the inbox (dequeue) or is dropped
|
||||
// (discard) only after it entered (enqueue), so the live outstanding count
|
||||
// per agent can never go negative. Injection bypasses the FIFOs entirely and
|
||||
// never appears on these events.
|
||||
const outstanding = new WeakMap<Agent, number>()
|
||||
ctx.on('agent/inbox/enqueue', (agent) => {
|
||||
outstanding.set(agent, (outstanding.get(agent) ?? 0) + 1)
|
||||
}, { global: true })
|
||||
ctx.on('agent/inbox/dequeue', (agent) => {
|
||||
const count = outstanding.get(agent) ?? 0
|
||||
if (count <= 0) fail('agent/inbox/dequeue without a matching prior enqueue')
|
||||
outstanding.set(agent, count - 1)
|
||||
}, { global: true })
|
||||
ctx.on('agent/inbox/discard', (agent, items) => {
|
||||
const count = outstanding.get(agent) ?? 0
|
||||
if (items.length > count) {
|
||||
fail(`agent/inbox/discard dropped ${items.length} items but only ${count} were outstanding`)
|
||||
}
|
||||
outstanding.set(agent, count - items.length)
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ContentBlock, LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
export type { AgentCancelCause } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
declare module '@deepseek-ai/dsh-system-prompt' {
|
||||
interface AssembleContext {
|
||||
@@ -27,95 +28,55 @@ export interface AgentOptions {
|
||||
maxTokens?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Which inbox queue a {@link Agent.send} item joins:
|
||||
* - `next-turn` — the item becomes its own turn, claimed at a turn boundary.
|
||||
* - `next-step` — during prompt admission or an open turn, the item stages for
|
||||
* the next safe step boundary; otherwise it is promoted per its `wakeup`
|
||||
* flag.
|
||||
*/
|
||||
export type SendTarget = 'next-turn' | 'next-step'
|
||||
|
||||
/** Resolved inbox placement reported when an accepted message is enqueued. */
|
||||
export type InboxPlacement = 'queued' | 'steering'
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* The object is complete so routing policy is explicit.
|
||||
*/
|
||||
export interface SendOptions {
|
||||
/** Queue the item joins. */
|
||||
target: SendTarget
|
||||
/**
|
||||
* Whether this item makes the model run: wake a parked driver (`next-turn`)
|
||||
* or force a continuation step (`next-step` while running). 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
|
||||
}
|
||||
|
||||
/** 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.
|
||||
* later turn and no `agent/inbox/canceled` fires.
|
||||
*/
|
||||
keepInbox?: boolean
|
||||
keepInbox?: boolean | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). Disposal removes the
|
||||
* agent from its registry; it is not a third observable status.
|
||||
* `idle` means no driver is scheduled or active; `running` begins when a
|
||||
* cancellable admission is scheduled and lasts while the driver drains,
|
||||
* closes, or checkpoints turns. Disposal removes the agent from its registry;
|
||||
* it is not a third observable status.
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running'
|
||||
|
||||
/**
|
||||
* Prompt interception result. `allow.content` replaces the prompt, while
|
||||
* `additionalContexts` appends model-facing context before the turn starts.
|
||||
* An `allow` returned by a listener is authoritative: a listener wrapping
|
||||
* `next()` preserves both fields unless it intentionally replaces them.
|
||||
* Prompt interception result. An allowed batch replaces the submitted
|
||||
* messages. A listener wrapping `next()` preserves the returned batch unless
|
||||
* it intentionally replaces it.
|
||||
*/
|
||||
export type PromptDecision =
|
||||
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessage[] }
|
||||
| { kind: 'block'; reason: string }
|
||||
| { kind: 'allow'; messages: UserMessage[] }
|
||||
| { kind: 'block'; reason: string; keepInbox?: boolean }
|
||||
|
||||
/** Model-request failure with an optional machine-routable provider code. */
|
||||
export type RequestError = Error & { code?: string }
|
||||
/** One failed model-request attempt presented to recovery listeners. */
|
||||
export interface RequestFailureContext {
|
||||
/** Turn containing the failed request. */
|
||||
readonly turn: number
|
||||
/** Step containing the failed request attempt. */
|
||||
readonly step: number
|
||||
/** Provider selected for the failed request. */
|
||||
readonly provider: string
|
||||
/** Serializable facts normalized at the final adapter boundary. */
|
||||
readonly failure: LlmFailure
|
||||
/** Policy of the adapter registration that served the failed request. */
|
||||
readonly retryPolicy: ResolvedRetryPolicy | undefined
|
||||
}
|
||||
|
||||
/** Action returned by a listener that owns model-request recovery. */
|
||||
export type RequestErrorAction = { kind: 'retry' } | undefined
|
||||
|
||||
/**
|
||||
* Why a turn ended, reported live on `agent/settled` right after the turn's
|
||||
* durable `turn/end`. `error` carries the thrown value verbatim for observers;
|
||||
* model-request recovery runs earlier through `agent/request-error`.
|
||||
*/
|
||||
export type SettleReason =
|
||||
| { kind: 'completed' }
|
||||
| { kind: 'aborted' }
|
||||
| { kind: 'error'; error: unknown; failure?: LlmFailure }
|
||||
|
||||
/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
|
||||
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
|
||||
|
||||
/** Stable runtime cause accepted by {@link Agent.cancel}. */
|
||||
export type AgentCancelCause =
|
||||
| { readonly kind: 'user' }
|
||||
| { readonly kind: 'parent' }
|
||||
|
||||
/** Runtime reason carried by the signal that controls one live turn. */
|
||||
export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' }
|
||||
|
||||
/** Public live-agent handle with aliases over the unified delivery primitive. */
|
||||
/** Public live-agent handle. */
|
||||
export interface Agent {
|
||||
/** The single identity shared with {@link session}. */
|
||||
readonly id: SessionId
|
||||
@@ -125,77 +86,46 @@ export interface Agent {
|
||||
readonly session: Session
|
||||
/** The current lifecycle state, mirrored on every `agent/status` transition. */
|
||||
readonly status: AgentStatus
|
||||
/**
|
||||
* Whether a `next-step` send currently stages for prompt admission or the
|
||||
* open turn. Unlike {@link status}, this excludes admission exit and turn
|
||||
* settlement, when a waking `next-step` send becomes a queued follow-up.
|
||||
*/
|
||||
readonly acceptsNextStep: boolean
|
||||
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
|
||||
readonly ctx: Context
|
||||
|
||||
/**
|
||||
* The unified delivery primitive over the (`target` × `wakeup`) matrix.
|
||||
* It routes the caller's typed content and source as follows:
|
||||
*
|
||||
* - `next-turn` queues an item that becomes the sole ordinary message of its
|
||||
* own FIFO-ordered turn; `wakeup:true` wakes a
|
||||
* parked driver, while `wakeup:false` queues without waking.
|
||||
* - `next-step` with `wakeup:true` stages steering during prompt admission
|
||||
* or an open turn; outside that window it falls back to a woken
|
||||
* `next-turn`.
|
||||
* - `next-step` with `wakeup:false` injects durable model-facing context
|
||||
* without running the model: admission or an open turn stages it for the
|
||||
* next safe log position, while an injection outside that window appends
|
||||
* immediately without opening a turn. If admission closes without a turn,
|
||||
* a context-only boundary appends immediately; context staged beside
|
||||
* steering remains pending with it.
|
||||
* The agent publishes or queues the identified frozen message as-is.
|
||||
* @param message - identified model-facing content and its producer provenance.
|
||||
* @param options - target queue and wakeup decision.
|
||||
*/
|
||||
send(message: UserMessage, 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. Idle
|
||||
* cancellation is a no-op and does not arm later work.
|
||||
* turn. The first cause wins for the active turn. Idle cancellation is a
|
||||
* no-op and does not arm later work.
|
||||
* @param cause - the stable caller intent carried by the current turn signal.
|
||||
* @param options - cancellation options; `keepInbox` preserves pending work.
|
||||
*/
|
||||
cancel(cause: AgentCancelCause, options?: CancelOptions): void
|
||||
|
||||
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
|
||||
/**
|
||||
* Resolve after the current whole-agent activity reaches quiescence. This
|
||||
* follows replacement work scheduled before the observed driver retires,
|
||||
* but does not identify the settlement of any particular message.
|
||||
* @returns fulfillment after no scheduled or active driver remains.
|
||||
*/
|
||||
whenIdle(): Promise<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.
|
||||
* Queue an ordinary follow-up turn and wake the driver. The item becomes the
|
||||
* sole ordinary message of its own turn.
|
||||
* @param message - identified prompt content and its producer provenance.
|
||||
*/
|
||||
followup(message: UserMessage): void
|
||||
|
||||
/**
|
||||
* Submit steering during prompt admission or an open turn — the
|
||||
* `next-step`/wakeup preset of {@link send}. It stages for the next steering
|
||||
* checkpoint before a request or stop decision. If the activity fails before
|
||||
* that boundary, the remainder stays staged without waking the agent; retry
|
||||
* or a later prompt takes it. Outside that window steering falls back to a
|
||||
* woken follow-up turn, while cancellation or disposal may discard pending
|
||||
* steering.
|
||||
* Submit steering for the nearest step. An idle driver schedules a turn;
|
||||
* collecting and running drivers consume it at their next step boundary.
|
||||
* Cancellation or disposal may discard pending steering.
|
||||
* @param message - identified steering content and its producer provenance.
|
||||
*/
|
||||
steer(message: UserMessage): void
|
||||
|
||||
/**
|
||||
* Append model-facing context without running the model — the
|
||||
* `next-step`/no-wakeup preset of {@link send}. Admission or an open turn
|
||||
* stages it at the next safe log position; outside that window it appends
|
||||
* immediately without opening a turn. If admission closes without a turn,
|
||||
* a context-only boundary appends immediately; context staged beside
|
||||
* Append model-facing context without running the model. Admission or an
|
||||
* open turn stages it at the next safe log position; outside that window it
|
||||
* appends immediately without opening a turn. If admission closes without a
|
||||
* turn, a context-only boundary appends immediately; context staged beside
|
||||
* steering remains pending with it.
|
||||
* @param message - identified injected context and its producer provenance.
|
||||
*/
|
||||
@@ -226,8 +156,9 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
|
||||
/**
|
||||
* Agent status changed (`idle` ⇄ `running`). `send()` does not enter
|
||||
* `running` synchronously; drive lifecycle from this event.
|
||||
* Agent status changed (`idle` ⇄ `running`). A waking delivery enters
|
||||
* `running` synchronously after reserving cancellation; `idle` means no
|
||||
* driver remains scheduled or active.
|
||||
* @param agent - the agent whose status flipped.
|
||||
* @param status - the status just entered (the transition's destination).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
@@ -235,56 +166,23 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
|
||||
/**
|
||||
* An item entered the queued or steering inbox. `placement` is the
|
||||
* acceptance-time routing result; listeners must not reconstruct it from
|
||||
* later agent or session state.
|
||||
* @param agent - the owning agent.
|
||||
* @param message - accepted content, source, and correlation identity.
|
||||
* @param placement - resolved queued or steering placement.
|
||||
* 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: UserMessage, placement: InboxPlacement): 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.
|
||||
* The driver admitted one inbox item for model-visible history.
|
||||
* @param agent - the agent whose inbox item was claimed.
|
||||
* @param message - the claimed message.
|
||||
* @param placement - the FIFO that claimed this occurrence; together with
|
||||
* `message.id`, it matches the earliest outstanding enqueue in that FIFO.
|
||||
* @param message - the admitted message.
|
||||
* 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: UserMessage,
|
||||
placement: InboxPlacement,
|
||||
): void
|
||||
'agent/inbox/admitted'(this: Scoped<Agent>, agent: Agent, message: UserMessage): void
|
||||
/**
|
||||
* Pending inbox items were dropped without delivering them, so every
|
||||
* enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR
|
||||
* `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,
|
||||
* emits this after `agent/cancel-requested` when applicable and before
|
||||
* aborting the active work. Fires once per drop with every dropped item.
|
||||
* One pending inbox item was dropped without entering model-visible
|
||||
* history. `cancel()` without `keepInbox`, including disposal, emits this
|
||||
* once for each dropped item before aborting active work.
|
||||
* @param agent - the agent whose inbox items were dropped.
|
||||
* @param messages - the discarded messages in FIFO order (queued then steering); never empty.
|
||||
* @param message - the dropped message.
|
||||
* 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: UserMessage[]): void
|
||||
/**
|
||||
* 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.
|
||||
* @param cause - the explicit typed cancellation cause.
|
||||
* 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
|
||||
|
||||
'agent/inbox/canceled'(this: Scoped<Agent>, agent: Agent, message: UserMessage): void
|
||||
// ---- session lifecycle (emit) ----
|
||||
/**
|
||||
* The session lifecycle began, once before the first turn. Use
|
||||
@@ -300,17 +198,17 @@ declare module 'cordis' {
|
||||
|
||||
// ---- the machine's extension seams ----
|
||||
/**
|
||||
* Allow, rewrite, or block one claimed prompt before it becomes a user
|
||||
* message or opens a turn. Call `next()` for the unchanged default. The
|
||||
* Allow, rewrite, or block one claimed inbox batch before it becomes
|
||||
* model-visible or opens a turn. Call `next()` for the unchanged default. The
|
||||
* signal controls only this admission attempt; listeners may cooperate with
|
||||
* it but must not retain it for a later attempt or turn.
|
||||
* @param agent - the agent whose turn claimed the message.
|
||||
* @param message - the frozen claimed message, including identity and source.
|
||||
* @param agent - the agent whose driver claimed the batch.
|
||||
* @param messages - the claimed messages.
|
||||
* @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/prompt-submit'(this: Scoped<Agent>, agent: Agent, message: UserMessage, signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
/**
|
||||
* Awaited serial checkpoint before EVERY request of a turn is built (the
|
||||
* first as well as each post-tools continuation). The single "between
|
||||
@@ -338,24 +236,17 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
/**
|
||||
* Handle a model-request failure after its failed step has closed but
|
||||
* before the failed turn closes. A listener returns `{ kind: 'retry' }`
|
||||
* without calling `next()` when it owns the error, or calls `next()` to
|
||||
* delegate. The default `undefined` leaves the failure terminal.
|
||||
* Handle one failed model-request attempt before the loop retries or closes
|
||||
* its step. A listener returns `{ kind: 'retry' }` without calling `next()`
|
||||
* when it owns recovery, or calls `next()` to delegate. The default
|
||||
* `undefined` leaves the failure terminal.
|
||||
* @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
|
||||
* retry turn in this consecutive sequence.
|
||||
* @param retryPolicy - immutable policy of the adapter registration that served
|
||||
* the failed request, or `undefined` if no final adapter served it.
|
||||
* @param context - request coordinates, provider, normalized failure, and serving policy.
|
||||
* @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[], retryPolicy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
|
||||
'agent/request-error'(this: Scoped<Agent>, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
|
||||
/**
|
||||
* The turn is about to close: the model owes no response (no live tool
|
||||
* calls, no fresh steering). Awaited before the boundary commits — a
|
||||
@@ -371,21 +262,6 @@ declare module 'cordis' {
|
||||
* @mode serial
|
||||
*/
|
||||
'agent/turn-stopping'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void
|
||||
/**
|
||||
* One drain chain reached its terminal turn: that turn's `turn/end` is
|
||||
* already committed. Automatically recovered failed turns do not emit this
|
||||
* notification, and neither does a run that aborts or fails before its
|
||||
* `turn/start` commits — there is no durable turn to settle against.
|
||||
* `reason` says why; model-request recovery is exhausted when an error
|
||||
* reaches it.
|
||||
* @param agent - the agent whose turn closed.
|
||||
* @param turn - the terminal turn number.
|
||||
* @param reason - why the terminal 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/settled'(this: Scoped<Agent>, agent: Agent, turn: number, reason: SettleReason): void
|
||||
|
||||
// ---- error notifications (emit) ----
|
||||
/**
|
||||
* A step or turn errored. The machine reports a failure here (plus the
|
||||
@@ -400,3 +276,10 @@ declare module 'cordis' {
|
||||
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: unknown): void
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/** One message was accepted into the agent inbox. */
|
||||
'agent/inbox/added': UserMessage
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user