refactor(agent-loop): simplify observable state machine
This commit is contained in:
@@ -46,7 +46,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
|
||||
|
||||
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
|
||||
|
||||
Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
|
||||
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener calls `agent.retry()` and returns without `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
|
||||
|
||||
`PromptDecision.additionalContexts` is an array so every context keeps its own source. Allowed prompt content and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative.
|
||||
|
||||
|
||||
@@ -66,7 +66,11 @@ export interface AgentEventDispatch {
|
||||
waterfall<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Return<Events[K]>
|
||||
}
|
||||
|
||||
/** Return the fused scope carrier for one agent subject. */
|
||||
/**
|
||||
* Return the fused scope carrier for one agent subject.
|
||||
* @param agent - the subject agent and scope key.
|
||||
* @returns the carrier passed as the event dispatcher `this` value.
|
||||
*/
|
||||
export function agentCarrier(agent: Agent): Scoped<Agent> {
|
||||
return scopeTarget(agent, agent)
|
||||
}
|
||||
@@ -116,7 +120,13 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
|
||||
}
|
||||
}
|
||||
|
||||
/** Emit one contained agent notification without allocating a retained dispatcher. */
|
||||
/**
|
||||
* Emit one contained agent notification without allocating a retained dispatcher.
|
||||
* @param ctx - the context to dispatch through.
|
||||
* @param agent - the subject agent and scope key.
|
||||
* @param name - the agent-subject event to emit.
|
||||
* @param rest - the event arguments after the injected agent.
|
||||
*/
|
||||
export function emitAgentEvent<K extends AgentSubjectEvent>(
|
||||
ctx: Context,
|
||||
agent: Agent,
|
||||
|
||||
@@ -103,8 +103,8 @@ export interface CancelOptions {
|
||||
/**
|
||||
* 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).
|
||||
* work and may be closing or checkpointing a turn). Disposal removes the
|
||||
* agent from its registry; it is not a third observable status.
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running'
|
||||
|
||||
@@ -121,11 +121,13 @@ export type PromptDecision =
|
||||
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: AdditionalContext[] }
|
||||
| { kind: 'block'; reason: string }
|
||||
|
||||
/** Model-request failure with an optional machine-routable provider code. */
|
||||
export type RequestError = Error & { code?: string }
|
||||
|
||||
/**
|
||||
* Why a turn ended, reported live on `agent/idle` right after the turn's
|
||||
* durable `turn/end` and flush. `error` carries the thrown value verbatim (and, for
|
||||
* model-request failures, the adapter-normalized facts) so a recovery
|
||||
* consumer can decide to repair and {@link Agent.retry}.
|
||||
* durable `turn/end`. `error` carries the thrown value verbatim for observers;
|
||||
* model-request recovery runs earlier through `agent/request-error`.
|
||||
*/
|
||||
export type IdleReason =
|
||||
| { kind: 'completed' }
|
||||
@@ -179,9 +181,8 @@ export abstract class Agent {
|
||||
* 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.
|
||||
* `whenIdle()` resolves after cancellation reaches quiescence. 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.
|
||||
*/
|
||||
@@ -245,11 +246,10 @@ export abstract class Agent {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* explicit resummon verb. During `agent/request-error`, this schedules one
|
||||
* retry turn after the failed turn closes; while idle, it starts one
|
||||
* immediately. Repeated calls before the scheduled retry coalesce.
|
||||
* @throws while other agent work is running.
|
||||
*/
|
||||
abstract retry(): void
|
||||
}
|
||||
@@ -270,7 +270,7 @@ declare module 'cordis' {
|
||||
'agent/created'(this: Scoped<Agent>, agent: Agent): void
|
||||
/**
|
||||
* An agent left the registry; AgentLoop emits this after driver quiescence
|
||||
* but before session detachment and scoped-registration unwind. Custom
|
||||
* and scoped-registration unwind, but before session detachment. Custom
|
||||
* registry users own their driver-ordering contract.
|
||||
* @param agent - the exact agent removed from the registry.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
@@ -278,8 +278,8 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
|
||||
/**
|
||||
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does
|
||||
* not enter `running` synchronously; drive lifecycle from this event.
|
||||
* Agent status 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).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
@@ -321,7 +321,7 @@ declare module 'cordis' {
|
||||
* 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 - resolved typed cancellation cause, including the default.
|
||||
* @param cause - the explicit typed cancellation cause.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
@@ -377,8 +377,23 @@ declare module 'cordis' {
|
||||
* @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/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 calls {@link Agent.retry} to
|
||||
* schedule one retry turn, returns without `next()` when it owns the error,
|
||||
* or calls `next()` to delegate. The default 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 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, signal: AbortSignal, next: () => Promise<void>): Promise<void>
|
||||
/**
|
||||
* The turn is about to close: the model owes no response (no live tool
|
||||
* calls, no fresh steering). Awaited before the boundary commits — a
|
||||
@@ -395,14 +410,13 @@ declare module 'cordis' {
|
||||
*/
|
||||
'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.
|
||||
* One drain chain reached its terminal turn: that turn's `turn/end` is
|
||||
* already committed. Automatically recovered failed turns do not emit this
|
||||
* notification. `reason` says why; model-request recovery is exhausted when
|
||||
* an error reaches it.
|
||||
* @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.
|
||||
* @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
|
||||
*/
|
||||
|
||||
@@ -12,7 +12,6 @@ import AgentRegistry, {
|
||||
import type {
|
||||
AgentCancelCause,
|
||||
AgentFactory,
|
||||
ContinuationStop,
|
||||
CreateAgentOptions,
|
||||
ResumeAgentOptions,
|
||||
SendOptions,
|
||||
@@ -30,6 +29,7 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
||||
ctx: new Context(),
|
||||
send: () => AgentMessageId('stub'),
|
||||
cancel() {},
|
||||
retry() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
...overrides,
|
||||
})
|
||||
@@ -58,14 +58,6 @@ describe('Agent delivery aliases', () => {
|
||||
})
|
||||
|
||||
describe('AgentRegistry', () => {
|
||||
it('allows terminal stop policy to cooperate asynchronously with turn cancellation', () => {
|
||||
type TurnStopListener = Events['agent/turn-stop']
|
||||
type AsyncTurnStopListener = () => Promise<ContinuationStop | undefined>
|
||||
|
||||
expectTypeOf<AsyncTurnStopListener>().toExtend<TurnStopListener>()
|
||||
expectTypeOf<Awaited<ReturnType<TurnStopListener>>>().toEqualTypeOf<ContinuationStop | undefined>()
|
||||
})
|
||||
|
||||
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
@@ -219,7 +211,7 @@ describe('agentEvents()', () => {
|
||||
|
||||
describe('explicit cancellation helpers', () => {
|
||||
it('exposes the closed typed cancellation cause at the Agent seam', () => {
|
||||
expectTypeOf<Parameters<Agent['cancel']>[0]>().toEqualTypeOf<AgentCancelCause | undefined>()
|
||||
expectTypeOf<Parameters<Agent['cancel']>[0]>().toEqualTypeOf<AgentCancelCause>()
|
||||
expectTypeOf<Parameters<Events['agent/cancel-requested']>[1]>().toEqualTypeOf<AgentCancelCause>()
|
||||
})
|
||||
|
||||
|
||||
@@ -17,19 +17,14 @@ function mockAgent(id: string): Agent {
|
||||
}
|
||||
|
||||
describe('agent status invariants', () => {
|
||||
it('accepts lifecycle transitions through idle, running, and disposed', async () => {
|
||||
it('accepts lifecycle transitions between idle and running', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = mockAgent('a1')
|
||||
expect(() => {
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
|
||||
}).not.toThrow()
|
||||
|
||||
const running = mockAgent('a2')
|
||||
ctx.emit(scopeTarget(running, running), 'agent/status', running, 'running')
|
||||
expect(() => { ctx.emit(scopeTarget(running, running), 'agent/status', running, 'disposed') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a no-op transition', async () => {
|
||||
@@ -40,14 +35,6 @@ describe('agent status invariants', () => {
|
||||
.toThrow(/no-op transition/)
|
||||
})
|
||||
|
||||
it('rejects leaving the terminal disposed state', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = mockAgent('a4')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
|
||||
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') })
|
||||
.toThrow(/left terminal state disposed/)
|
||||
})
|
||||
|
||||
it('tracks agents independently', async () => {
|
||||
const ctx = await setup()
|
||||
const a = mockAgent('a5')
|
||||
|
||||
@@ -21,25 +21,25 @@ describe('installAgentLlmTarget()', () => {
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
|
||||
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
|
||||
)).resolves.toBe(seed)
|
||||
|
||||
target.current = { provider: 'alpha', model: 'a1' }
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' })
|
||||
target.current = { provider: 'beta', model: 'b1' }
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
|
||||
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
|
||||
)).resolves.toEqual({ provider: 'alpha', model: 'a1', temperature: 0.2 })
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'beta', model: 'b1' })
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 1, seed, signal, () => Promise.resolve(seed),
|
||||
'agent/request', 1, 1, signal, () => Promise.resolve(seed),
|
||||
)).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
|
||||
|
||||
dispose()
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 2, 0, seed, signal, () => Promise.resolve(seed),
|
||||
'agent/request', 2, 0, signal, () => Promise.resolve(seed),
|
||||
)).resolves.toBe(seed)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user