refactor(agent-loop): separate injected context from turns

This commit is contained in:
_Kerman
2026-07-24 16:05:52 +08:00
parent 712448a2d2
commit 45fc7fda3d
45 changed files with 470 additions and 873 deletions

View File

@@ -52,7 +52,7 @@ Configured agents start automatically. A model call requires both `provider` and
The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
The unified `send()` primitive materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON record, then routes it by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO (waking the driver unless `wakeup: false`); if claimed, it is the sole ordinary message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. A running `next-step`/wakeup `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `next-step`/no-wakeup `inject()` bypasses the FIFOs and appends durable context directly: an open-turn injection uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append.
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. An allowed prompt and the prompt waterfall's `additionalContexts` enter the outbox as separate messages, then `run()` opens the turn and drains them together. A running `next-step`/wakeup `steer()` enters the same outbox without prompt admission and normally causes another step. A `next-step`/no-wakeup `inject()` waits there only while a turn is open; while idle it appends and flushes a `user/message` immediately without opening a turn or running the model. Every inbox enqueue publishes `agent/inbox/enqueue`; taking it publishes `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
### Loop lifecycle (`loop.ts`)

View File

@@ -1,16 +1,7 @@
/**
* The concrete Agent, in the naive-agent shape: the agent IS the machine.
* Two inboxes — `queued` (prompts, one turn each) and `outbox` (steering +
* injected or admitted input, taken at turn start and every step boundary).
* `kick()` claims one queued prompt and resolves admission before `run()` owns
* the turn boundaries, step loop, settlement, and idle handoff.
*
* The session log IS the transcript: every take appends, every step re-derives
* (`session.deriveMessages()`), so editing history between steps is naturally
* legal — recovery is "observe the error idle, repair the log, retry()".
* Because the outbox is only ever taken at a step boundary, nothing can land
* between an assistant tool-call batch and its results; wire adjacency needs
* no dedicated machinery.
* Concrete Agent loop over two pending-input lists: queued prompts each open a
* turn, while admitted input, steering, and injected context enter through the
* outbox at step boundaries. Every request is derived from the session log.
*
* @module dsh-agent-loop/agent
*/
@@ -27,7 +18,6 @@ import type {
AgentInterruptReason,
AgentOptions,
AgentStatus,
HookContext,
IdleReason,
PromptDecision,
SendOptions,
@@ -44,66 +34,31 @@ 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 {
/** One message waiting in the queued or steering inbox. */
interface PendingMessage {
id: AgentMessageIdType
content: ContentBlock[]
source: MessageSource
contexts: HookContext[]
wakeup: boolean
}
/** One model-facing input awaiting the next step boundary. */
interface OutboxItem {
data: PromptMessageData
steering?: QueuedMessage
/** Model-facing input awaiting the next step boundary. */
interface OutboxItem extends PromptMessageData {
/** Present only when this input is a live inbox item. */
steering?: PendingMessage
}
/** Build one live inbox event payload from a queued message. */
function inboxMessage(message: QueuedMessage, steering: boolean): AgentMessage {
/** Build one live inbox event payload from a pending message. */
function inboxMessage(message: PendingMessage, 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',
}
/** Bake prompt-prefix contexts into one reconstructable prompt event. */
function preparePromptMessage(
content: ContentBlock[],
source: MessageSource,
contexts: readonly HookContext[],
): { data: PromptMessageData; separateContexts: HookContext[] } {
const prefixContexts = contexts.filter(context => context.placement === 'prompt-prefix')
const separateContexts = contexts.filter(context => context.placement !== 'prompt-prefix')
if (prefixContexts.length === 0) return { data: { content, source }, separateContexts }
return {
data: {
content: [
...prefixContexts.flatMap(context => context.content),
PROMPT_PREFIX_REQUEST_DELIMITER,
...content,
],
source,
envelope: {
displayContent: content,
prefixContexts: prefixContexts.map(context => ({
source: context.source,
})),
},
},
separateContexts,
}
}
/** Stable runtime-only reason used when lifecycle teardown interrupts a turn. */
export const DISPOSED_INTERRUPT_REASON = Object.freeze({ kind: 'disposed' } as const)
@@ -126,26 +81,21 @@ function withoutToolCalls(message: Message): Message {
return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
}
// ---------------------------------------------------------------------------
// The agent.
// ---------------------------------------------------------------------------
/**
* The concrete {@link Agent}: the classic naive agent loop — whole derived
* history in, one assistant message out, loop until a reply owes no tool call.
* One `run()` owns one complete turn.
* The concrete {@link Agent}: each `run()` owns one turn and repeats model
* steps while tools or steering require another request.
*/
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). */
/** Prompts awaiting individual turns. */
private queued: PendingMessage[] = []
/** Input taken into the session log at step boundaries. */
private outbox: OutboxItem[] = []
/** Whether observers see one running drain interval; queued turns share it. */
/** Whether observers see a running interval; consecutive turns share it. */
private busy = false
/** The claimed activity's abort owner, spanning prompt admission and its turn. */
private turnAbort: AbortController | undefined
/** Resolves when the current admission-plus-turn activity fully exits. */
/** Abort owner for the current admission or turn. */
private abort: AbortController | undefined
/** Resolves when the current admission and turn exit. */
done: Promise<void> = Promise.resolve()
/** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */
@@ -153,13 +103,9 @@ export class ReactLoopAgent extends Agent {
/** The agent's scoped composition context ({@link Agent.ctx}). */
readonly ctx: Context
/**
* The last turn number this machine (or the seeded log) opened. The machine
* is the session's only turn author, so after the one seed scan below it
* simply counts.
*/
/** Last turn number opened by this loop or present in its seeded log. */
private lastTurn: number
/** Whether the machine owes the log a `turn/end` / `step/end` right now. */
/** Whether the session log is owed a matching turn end event. */
private turnOpen = false
private stepOpen = false
@@ -171,7 +117,6 @@ export class ReactLoopAgent extends Agent {
) {
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 })
}
@@ -181,51 +126,35 @@ export class ReactLoopAgent extends Agent {
return this.busy ? 'running' : 'idle'
}
// -------------------------------------------------------------------------
// Public driving verbs.
// -------------------------------------------------------------------------
/** 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) {
const context: HookContext = {
content,
source: options.source ?? { kind: 'plugin', plugin: '' },
}
if (this.turnAbort !== undefined) {
this.outbox.push({ data: { content: context.content, source: context.source } })
const source = options.source ?? { kind: 'plugin', plugin: '' }
if (this.turnOpen) {
this.outbox.push({ content, source })
return id
}
const turn = ++this.lastTurn
let opened = false
try {
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source: context.source } })
opened = true
this.session.append('user/message', context, { surfaceOp: 'append' })
} finally {
if (opened) this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
this.session.append('user/message', { content, source }, { surfaceOp: 'append' })
const previous = this.done
const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${errorChain(toError(error))}`)
})
this.done = Promise.all([previous, flush]).then(() => undefined)
return id
}
const steering = target === 'next-step' && this.turnAbort !== undefined
const message: QueuedMessage = {
const steering = target === 'next-step' && this.turnOpen
const message: PendingMessage = {
id,
content,
source: options.source ?? { kind: 'user' },
contexts: options.contexts ?? [],
wakeup,
}
if (steering) {
const prepared = preparePromptMessage(message.content, message.source, message.contexts)
this.outbox.push({ data: prepared.data, steering: message })
for (const context of prepared.separateContexts) {
this.outbox.push({ data: { content: context.content, source: context.source } })
}
this.outbox.push({ content: message.content, source: message.source, steering: message })
} else {
this.queued.push(message)
}
@@ -242,7 +171,7 @@ export class ReactLoopAgent extends Agent {
* all owned by the factory.
*/
cancel(cause: AgentInterruptReason, options: CancelOptions = {}): void {
if (this.turnAbort !== undefined || this.queued.length > 0 || this.outbox.length > 0) {
if (this.abort !== 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.
if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause)
@@ -258,7 +187,7 @@ export class ReactLoopAgent extends Agent {
if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded)
}
const reason = Object.freeze({ kind: cause.kind })
this.turnAbort?.abort(reason)
this.abort?.abort(reason)
}
/**
@@ -268,133 +197,74 @@ export class ReactLoopAgent extends Agent {
* @throws while a turn is running — there is nothing to retry yet.
*/
retry(): void {
if (this.turnAbort !== undefined) throw new Error(`agent "${this.id}" cannot retry while busy`)
this.done = this.loopCtx.agents.withInitiator(this, () => this.run({ kind: 'retry' }))
if (this.abort !== undefined) throw new Error(`agent "${this.id}" cannot retry while busy`)
const previous = this.done
const run = this.loopCtx.agents.withInitiator(this, () => this.run({ kind: 'retry' }))
this.done = Promise.all([previous, run]).then(() => undefined)
}
/** 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.turnAbort !== undefined || this.queued.some(message => message.wakeup)) {
await this.done.catch(() => undefined)
// `done` is replaced by runs and idle-injection flushes. Re-read after
// every settlement so work admitted by a synchronous observer is included.
while (true) {
const done = this.done
await done.catch(() => undefined)
if (done === this.done && this.abort === undefined && !this.queued.some(message => message.wakeup)) return
}
}
// -------------------------------------------------------------------------
// The machine.
// -------------------------------------------------------------------------
/** Claim and admit the next queued prompt, then start its turn. */
private kick(): void {
if (this.turnAbort !== undefined || !this.queued.some(message => message.wakeup)) return
if (this.abort !== undefined || !this.queued.some(message => message.wakeup)) return
const message = this.queued.shift()
if (message === undefined) return
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(message, false))
const controller = new AbortController()
this.turnAbort = controller
this.done = this.loopCtx.agents.withInitiator(this, async () => {
const signal = controller.signal
const admission = new AbortController()
this.abort = admission
const previous = this.done
const admissionTask = this.loopCtx.agents.withInitiator(this, async () => {
const signal = admission.signal
const trigger: TurnTrigger = { kind: 'message', source: message.source }
let admitted = false
try {
signal.throwIfAborted()
const decision = await this.loopCtx.waterfall(
agentCarrier(this), 'agent/prompt-submit', this, message.content, message.source, signal,
() => Promise.resolve<PromptDecision>({
kind: 'allow',
...message.contexts.length === 0 ? {} : { additionalContexts: message.contexts },
}),
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
)
signal.throwIfAborted()
if (decision.kind === 'block') {
this.rejectPrompt(trigger, message, decision.reason, controller)
return
} else {
const prepared = preparePromptMessage(
decision.content ?? message.content,
message.source,
decision.additionalContexts ?? [],
)
this.outbox.push({ data: prepared.data })
for (const context of prepared.separateContexts) {
this.outbox.push({ data: { content: context.content, source: context.source } })
if (decision.kind === 'allow') {
this.outbox.push({ content: decision.content ?? message.content, source: message.source })
for (const context of decision.additionalContexts ?? []) {
this.outbox.push({ content: context.content, source: context.source })
}
admitted = true
}
} catch (error: unknown) {
this.failAdmission(trigger, error, controller)
if (agentInterruptReasonOf(signal) === undefined) {
const failure = toError(error)
this.loopCtx.logger.warn(`agent "${this.id}": prompt admission failed: ${errorChain(failure)}`)
}
}
if (this.abort === admission) this.abort = undefined
if (!admitted) {
this.continueOrIdle()
return
}
if (this.turnAbort === controller) this.turnAbort = undefined
await this.run(trigger)
})
}
/** Record a policy-blocked prompt as a zero-step turn. */
private rejectPrompt(
trigger: TurnTrigger,
message: QueuedMessage,
rejection: string,
controller: AbortController,
): void {
if (!this.busy) {
this.busy = true
emitAgentEvent(this.loopCtx, this, 'agent/status', 'running')
}
const signal = controller.signal
const turn = ++this.lastTurn
let reason: TurnEndReason = { kind: 'rejected', reason: rejection }
let idle: IdleReason = { kind: 'completed' }
try {
signal.throwIfAborted()
this.session.append('turn/start', { turn, trigger })
this.turnOpen = true
signal.throwIfAborted()
this.drainOutbox(turn)
this.session.append('prompt/blocked', {
content: message.content,
source: message.source,
reason: rejection,
})
} catch (error: unknown) {
({ reason, idle } = this.settle(turn, 0, error, signal))
} finally {
this.finishTurn(controller, turn, 0, reason, idle)
}
}
/** Settle a prompt-admission failure without entering the step loop. */
private failAdmission(trigger: TurnTrigger, failure: unknown, controller: AbortController): void {
if (!this.busy) {
this.busy = true
emitAgentEvent(this.loopCtx, this, 'agent/status', 'running')
}
const signal = controller.signal
const turn = ++this.lastTurn
let reason: TurnEndReason = { kind: 'completed' }
let idle: IdleReason = { kind: 'completed' }
try {
signal.throwIfAborted()
this.session.append('turn/start', { turn, trigger })
this.turnOpen = true
signal.throwIfAborted()
this.drainOutbox(turn)
throw failure
} catch (error: unknown) {
({ reason, idle } = this.settle(turn, 0, error, signal))
} finally {
this.finishTurn(controller, turn, 0, reason, idle)
}
this.done = Promise.all([previous, admissionTask]).then(() => undefined)
}
/** Own one complete turn over input already admitted by {@link kick}, or retry history as-is. */
private async run(trigger: TurnTrigger): Promise<void> {
if (this.turnAbort !== undefined) throw new Error(`agent "${this.id}" is already running`)
if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`)
const controller = new AbortController()
this.turnAbort = controller
this.abort = controller
if (!this.busy) {
this.busy = true
emitAgentEvent(this.loopCtx, this, 'agent/status', 'running')
@@ -415,9 +285,9 @@ export class ReactLoopAgent extends Agent {
while (true) {
step += 1
const { owes, maxTokens } = await this.step(turn, step, signal)
const { continueTurn, maxTokens } = await this.step(turn, step, signal)
if (maxTokens) reason = { kind: 'max-tokens' }
if (owes || this.outbox.some(item => item.steering !== undefined)) continue
if (continueTurn || this.outbox.some(item => item.steering !== undefined)) continue
await this.loopCtx.serial(agentCarrier(this), 'agent/stopping', this, turn, signal)
signal.throwIfAborted()
if (!this.drainOutbox(turn)) break
@@ -425,17 +295,36 @@ export class ReactLoopAgent extends Agent {
} catch (error: unknown) {
({ reason, idle } = this.settle(turn, step, error, signal))
} finally {
this.finishTurn(controller, turn, step, reason, idle)
try {
if (this.stepOpen) {
this.stepOpen = false
this.session.append('step/end', { turn, step })
}
if (this.turnOpen) {
// Re-entrant turn/end listeners must route new input to a later turn.
this.turnOpen = false
this.session.append('turn/end', { turn, reason })
}
} catch (error: unknown) {
const err = toError(error)
this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(err)}`)
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, err)
}
if (this.abort === controller) this.abort = undefined
emitAgentEvent(this.loopCtx, this, 'agent/idle', turn, idle)
this.continueOrIdle()
}
}
/**
* One whole step: the `agent/step` seam, take the outbox, derive the
* history, one request, its tool calls — bracketed by the durable
* step/start / step/end pair. The naive core: whole history in, one
* assistant message out.
* Run the `agent/step` seam, commit pending input, derive one request, and
* execute its tool calls inside one durable step boundary.
*/
private async step(turn: number, step: number, signal: AbortSignal): Promise<{ owes: boolean; maxTokens: boolean }> {
private async step(
turn: number,
step: number,
signal: AbortSignal,
): Promise<{ continueTurn: boolean; maxTokens: boolean }> {
const { session } = this
// The single between-steps seam: listeners inject, steer, or edit the log
@@ -462,7 +351,6 @@ export class ReactLoopAgent extends Agent {
const request = await this.buildRequest(turn, step, assembly.tools, system, boundaryMessages, signal)
// --- Model call (streaming-first; raw chunks are the replay record) ---
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
const stream = this.loopCtx.llm.stream(request)
@@ -507,26 +395,22 @@ export class ReactLoopAgent extends Agent {
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
)
// Dispatch may overlap; policy, durable results, and result context stay
// model-ordered. Tool-produced context rides the outbox like any other
// injection, so it lands after the batch's results — adjacency-safe.
const toolCalls = assembled.content.filter(block => block.type === 'tool-call')
let concluded = false
if (toolCalls.length > 0) {
({ concluded } = await executeToolCalls(
this.loopCtx, turn, step, toolCalls, signal,
context => this.outbox.push({ data: { content: context.content, source: context.source } }),
context => this.outbox.push({ content: context.content, source: context.source }),
))
}
// Steering/context that arrived during streaming or tool execution lands
// inside the step (after the batch's results — adjacency-safe).
// Tool results stay adjacent to their calls; input accepted during the
// request enters the log only after the complete result batch.
const steered = this.drainOutbox(turn)
session.append('step/end', { turn, step })
this.stepOpen = false
// Owed: live tool calls none of which concluded the turn, or steering.
return {
owes: (toolCalls.length > 0 && !concluded) || steered,
continueTurn: (toolCalls.length > 0 && !concluded) || steered,
maxTokens: finish.kind === 'max-tokens',
}
}
@@ -566,7 +450,7 @@ export class ReactLoopAgent extends Agent {
...system ? { system } : {},
...tools.length > 0 ? { tools } : {},
})
// Log the header the request will ACTUALLY use, only when it differs
// Log the header the request will use only when it differs
// from the folded baseline — reconstruction folds the log, so an
// unchanged header needs no new snapshot.
const baseline = session.requestHeader()
@@ -588,18 +472,18 @@ export class ReactLoopAgent extends Agent {
}))
}
/** Commit the outbox whole and report whether it contained steering. */
/** Commit the outbox and report whether it contained steering. */
private drainOutbox(turn: number): boolean {
let steered = false
for (const item of this.outbox.splice(0)) {
const message = item.steering
const { steering: message, ...data } = item
if (message === undefined) {
this.session.append('user/message', item.data, { surfaceOp: 'append' })
this.session.append('user/message', data, { surfaceOp: 'append' })
continue
}
steered = true
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(message, true))
this.session.append('steering/message', { turn, ...item.data }, { surfaceOp: 'append' })
this.session.append('steering/message', { turn, ...data }, { surfaceOp: 'append' })
}
return steered
}
@@ -632,61 +516,12 @@ export class ReactLoopAgent extends Agent {
}
}
/** Close the owed boundaries, exactly once per turn. Durability is persistence's own eager concern. */
private closeTurn(turn: number, step: number, reason: TurnEndReason): void {
if (this.stepOpen) {
this.stepOpen = false
this.session.append('step/end', { turn, step })
}
if (this.turnOpen) {
this.turnOpen = false
this.session.append('turn/end', { turn, reason })
}
}
/** Close one claimed turn and hand the machine back to the idle boundary. */
private finishTurn(
controller: AbortController,
turn: number,
step: number,
reason: TurnEndReason,
idle: IdleReason,
): void {
try {
this.closeTurn(turn, step, reason)
} catch (error: unknown) {
// A rejected boundary append must not strand the running interval.
const err = toError(error)
this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(err)}`)
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, err)
}
if (this.turnAbort === controller) this.turnAbort = undefined
this.idle(turn, idle)
}
/**
* 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 {
// 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)) {
const message = item.steering
if (message === undefined) {
this.outbox.push(item)
continue
}
this.queued.push(message)
}
emitAgentEvent(this.loopCtx, this, 'agent/idle', turn, idle)
// 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 {
/** Continue with a waking prompt, or publish the idle status. */
private continueOrIdle(): void {
if (this.abort !== undefined) return
if (this.queued.some(message => message.wakeup)) {
this.kick()
} else if (this.busy) {
this.busy = false
emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle')
}

View File

@@ -11,7 +11,7 @@
import type { Context } from 'cordis'
import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
import type { HookContext } from '@deepseek-ai/dsh-agent'
import type { AdditionalContext } from '@deepseek-ai/dsh-agent'
import type { Session } from '@deepseek-ai/dsh-session'
import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
@@ -58,7 +58,7 @@ export async function executeToolCalls(
step: number,
toolCalls: ToolCallBlock[],
signal: AbortSignal,
acceptContext: (context: HookContext) => void,
acceptContext: (context: AdditionalContext) => void,
): Promise<{ concluded: boolean }> {
const agent = ctx.agents.requireInitiator()
const { session } = agent
@@ -120,7 +120,7 @@ async function runGroup(
group: PlannedCall[],
mode: ToolExecutionMode['kind'],
signal: AbortSignal,
acceptContext: (context: HookContext) => void,
acceptContext: (context: AdditionalContext) => void,
): Promise<GroupOutcome> {
const { session } = ctx.agents.requireInitiator()
const { maxParallelToolCalls } = ctx.agentLoop.config

View File

@@ -25,8 +25,8 @@ New emit: `agent/idle (agent, turn, reason: IdleReason)` fires once per closed t
- `send()` — unchanged (queued FIFO, one turn each).
- `steer()` while running — enters the outbox; taken whole at the next step
boundary. Steering left when the turn closes becomes a queued prompt.
There is NO terminal-stop discard of steering anymore.
boundary. A turn failure leaves untaken steering staged without waking the
agent; `retry()` or a later prompt takes it.
- `inject()` while the machine is busy — enters the outbox (a `context/message`
appears at the NEXT step boundary, not immediately). While idle — writes a
one-shot turn (`turn/start(injection)` + `context/message` + `turn/end`) and
@@ -43,10 +43,10 @@ New emit: `agent/idle (agent, turn, reason: IdleReason)` fires once per closed t
- `kick()` runs SYNCHRONOUSLY from `send()` when idle: status flips to
`running` inside the `send()` call. There is no parked driver loop, no
waitForQueued, no microtask collection window.
- One `run()` = one turn. The idle tail (`idle()`) runs after turn/end +
flush: it sets `busy=false`, emits `agent/idle`, requeues leftover steering,
then either kicks the next turn or settles `whenIdle` waiters and flips
status to `idle`. Status stays `running` continuously across queued turns.
- One `run()` = one turn. After `turn/end`, it emits `agent/idle`, then either
starts the next waking queued prompt or flips status to `idle`. Residual
outbox input does not wake the agent. Status stays `running` continuously
across queued turns.
- `step/end` is appended INSIDE the step (after tools + the in-step outbox
drain), before `agent/continue` runs. The old `post-step → step/end`
window no longer exists.

View File

@@ -83,130 +83,71 @@ describe('Agent', () => {
await ctx.fiber.dispose()
})
it('inject() decides enclosure from the LOG (open turn), not agent status', async () => {
it('idle inject() appends context and flushes without opening a turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const release = Promise.withResolvers<void>()
let flushes = 0
ctx.on('session/flush', async (session) => {
if (session !== agent.session) return
flushes += 1
await release.promise
})
// Simulate an OPEN turn in the log while the agent is idle (status is not a
// reliable open-turn signal). inject must append into that open turn, NOT
// wrap a new one.
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } })
expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.at(-1)!.type).toBe('user/message')
agent.inject([{ type: 'text', text: 'context' }], { source: { kind: 'plugin', plugin: 'p' } })
expect(agent.session.events.map(event => event.type)).toEqual(['user/message'])
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0)
// Close the turn; now inject must wrap its own one-shot injection turn.
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
agent.inject([{ type: 'text', text: 'after' }], { source: { kind: 'plugin', plugin: 'p' } })
const starts = agent.session.events.filter(e => e.type === 'turn/start')
expect(starts).toHaveLength(2)
const last = starts[1]!
expect(last.type === 'turn/start' && last.data.trigger.kind).toBe('injection')
expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed
let idle = false
const settled = agent.whenIdle().then(() => { idle = true })
await Promise.resolve()
expect(flushes).toBe(1)
expect(idle).toBe(false)
release.resolve()
await settled
})
it('inject() defaults its source to an empty plugin, never user', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
agent.inject([{ type: 'text', text: 'no explicit source' }])
const injected = agent.session.events.at(-1)!
expect(injected.type === 'user/message' && injected.data.source).toEqual({ kind: 'plugin', plugin: '' })
await agent.whenIdle()
})
it('idle inject() contains a failing flush (logs, does not throw into the caller)', async () => {
it('idle inject() contains a failing flush without inventing an agent turn error', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
// A persistence-like listener whose flush rejects.
ctx.on('session/flush', () => { throw new Error('disk gone') })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
// inject() is synchronous and fires a fire-and-forget flush; a rejecting
// flush must be contained (logged), never thrown into the caller.
expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow()
await new Promise(r => setTimeout(r, 20)) // let the contained flush settle
await agent.whenIdle()
expect(errors).toEqual([])
expect(agent.session.events.map(event => event.type)).toEqual(['user/message'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('flush after idle injection failed'))
warn.mockRestore()
})
it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => {
it('idle inject() does not flush input rejected before append', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// Non-serializable injected content makes Session.append throw AFTER
// turn/start was recorded. The turn/end must still be appended (finally),
// AND the durability checkpoint must still fire — the balanced turn is in
// memory and a crash before the next turn/dispose would otherwise lose it.
expect(() => {
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
}).toThrow(/non-JSON-serializable/)
const types = agent.session.events.map(e => e.type)
expect(types).toEqual(['turn/start', 'turn/end']) // balanced, no open turn
await new Promise(r => setTimeout(r, 10)) // let the fire-and-forget flush run
expect(flushes).toBe(1) // checkpoint fired despite the throw
})
it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// Session contains a throwing post-commit turn/end observer. The accepted
// boundary still triggers the idle injection's durability checkpoint.
let threw = false
ctx.on('session/event', (_s, event) => {
if (!threw && event.type === 'turn/end') { threw = true; throw new Error('boom turn/end') }
})
expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow()
const types = agent.session.events.map(e => e.type)
expect(types).toEqual(['turn/start', 'user/message', 'turn/end']) // balanced
await new Promise(r => setTimeout(r, 10))
expect(flushes).toBe(1) // checkpoint fired despite the throwing turn/end listener
})
it('idle inject() reports a failing flush via agent/error (step 0) AND the logger', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
// A non-Error rejection exercises the String() normalization branch.
ctx.on('session/flush', () => { throw 'disk gone' })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const errors: { turn: number; step: number; message: string }[] = []
ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } })
await new Promise(r => setTimeout(r, 20)) // let the contained flush settle
// Reported via agent/error (step 0 — the idle-injection convention) so
// plugins monitoring agent/error see idle-injection persistence failures,
// mirroring the loop's post-turn/end flush path. A non-Error throw is
// normalized to an Error.
expect(errors).toEqual([{ turn: 1, step: 0, message: 'disk gone' }])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('flush after idle injection failed'))
warn.mockRestore()
})
it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// A non-serializable source makes the turn/start append throw BEFORE the
// event is pushed (Session.append validates before push), so NO turn opens.
// The finally's isTurnOpen() guard sees no open turn and appends nothing —
// the log stays empty, not left with a dangling turn/start.
expect(() => {
agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
}).toThrow(/non-JSON-serializable/)
expect(agent.session.events).toHaveLength(0)
expect(flushes).toBe(0)
})
it('steer() when idle falls through to send() and starts a turn', async () => {

View File

@@ -114,53 +114,6 @@ describe('agent/prompt-submit', () => {
expect(sent).toContain('extra ctx')
})
it('bakes prompt-prefix contexts and a request delimiter into one durable user message', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('prefixed'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (_agent, _content, _source, _signal, next): Promise<PromptDecision> => {
const downstream = await next()
return downstream.kind === 'block'
? downstream
: { ...downstream, content: [{ type: 'text', text: 'rewritten request' }] }
})
agent.send([{ type: 'text', text: 'original request' }], {
contexts: [{
content: [{ type: 'text', text: 'untrusted prefix' }],
source: { kind: 'plugin', plugin: 'prefix' },
placement: 'prompt-prefix',
}],
})
await waitForIdle(ctx, agent)
const log = events(agent)
const user = log.find(event => event.type === 'user/message')
expect(user?.type === 'user/message' && user.data).toEqual({
content: [
{ type: 'text', text: 'untrusted prefix' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'rewritten request' },
],
source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'rewritten request' }],
prefixContexts: [{
source: { kind: 'plugin', plugin: 'prefix' },
}],
},
})
expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
expect(adapter.requests[0]?.messages.at(-1)).toEqual({
role: 'user',
content: [
{ type: 'text', text: 'untrusted prefix' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'rewritten request' },
],
})
})
it('runs pre-step after prompt rewrites and injected context become durable', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
@@ -198,9 +151,7 @@ describe('agent/prompt-submit', () => {
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
agent.send([{ type: 'text', text: 'do something' }], {
contexts: [{ content: [{ type: 'text', text: 'must be dropped' }], source: { kind: 'plugin', plugin: 'test' } }],
})
agent.send([{ type: 'text', text: 'do something' }])
await waitForIdle(ctx, agent)
// the model was never called

View File

@@ -374,25 +374,52 @@ describe('agent loop', () => {
expect(adapter.requests).toHaveLength(2)
})
it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => {
it('keeps steering staged after a failed step until retry', async () => {
const adapter = new MockAdapter([textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' })
let fail = true
ctx.on('agent/step', (subject) => {
if (subject !== agent || !fail) return
fail = false
subject.steer([{ type: 'text', text: 'pending steering' }])
throw new Error('step failed')
})
send(agent, 'prompt')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
const idle = waitForIdle(ctx, agent)
agent.retry()
await idle
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(true)
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering')
})
it('inject() while idle appends context without opening a turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } })
// The idle inject records a self-contained turn (turn/start → user/message
// → turn/end) so the event stays turn-enclosed, but does NOT run the model.
await new Promise(r => setTimeout(r, 20))
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0)
const injectedTurn = agent.session.events.filter(e => e.type === 'turn/start')
expect(injectedTurn).toHaveLength(1)
const it0 = injectedTurn[0]!
expect(it0.type === 'turn/start' && it0.data.trigger.kind).toBe('injection')
expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'user/message',
data: { source: { kind: 'plugin', plugin: 'watcher' } },
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
const flat = JSON.stringify(adapter.requests[0]!.messages)
expect(flat).toContain('file changed: a.ts')
expect(flat).not.toContain('<context source=')

View File

@@ -454,16 +454,15 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
})
it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => {
// Idle injection creates and flushes a one-shot turn. No explicit flush or
// clean disposal follows, so disk presence proves its own checkpoint ran.
// No clean disposal follows, so disk presence proves the idle injection's
// own checkpoint ran without a synthetic turn.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
// Let inject()'s fire-and-forget flush settle (NO explicit flush/dispose).
await new Promise(r => setTimeout(r, 30))
await a1.whenIdle()
// A SEPARATE backend reads the on-disk log — proving the inject persisted
// itself, not a later dispose drain.
@@ -476,16 +475,14 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await ctx1.fiber.dispose()
})
it('an idle inject() survives persist + resume (turn-enclosed, not dropped as crash tail)', async () => {
// Turn enclosure keeps idle context out of crash-tail repair, so it must
// survive persistence and resume.
it('an idle inject() survives persist + resume without a synthetic turn', async () => {
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
await ctx1.sessions.flush(a1.session)
await a1.whenIdle()
await ctx1.fiber.dispose()
// Lifecycle 2: resume; the injected context is still in the derived history.