Implement the agent loop plugin

@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.

16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
This commit is contained in:
Tianyi Cui
2026-06-11 10:54:31 +08:00
parent d5a1d9bb75
commit 43f4258277
8 changed files with 965 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
import type { Context } from 'cordis'
import type { AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import { Inbox } from './inbox.ts'
import { runLoop } from './loop.ts'
/**
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
*
* Owns the inbox (queued + steering FIFOs), the per-step AbortController, and
* the loop driver. Everything observable happens through session events and
* the agent/* event taxonomy — plugins never need this class.
*/
export class LoopAgent implements Agent {
readonly inbox = new Inbox()
private _status: AgentStatus = 'idle'
private currentAbort: AbortController | undefined
private disposed: Promise<void>
private resolveDisposed!: () => void
/** Resolves when the driver loop has fully exited (tests/disposal). */
done: Promise<void> = Promise.resolve()
constructor(
private ctx: Context,
public readonly id: string,
public readonly options: AgentOptions,
public readonly session: Session,
) {
const { promise, resolve } = Promise.withResolvers<void>()
this.disposed = promise
this.resolveDisposed = resolve
}
get status(): AgentStatus {
return this._status
}
private setStatus(status: AgentStatus): void {
if (this._status === status || this._status === 'disposed') return
this._status = status
this.ctx.emit('agent/status', this, status)
}
send(content: ContentBlock[], options?: SendOptions): void {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
const source = options?.source ?? { kind: 'user' as const }
this.inbox.enqueue({ content, source })
this.ctx.emit('agent/queued', this, content, { ...options, steering: false })
}
steer(content: ContentBlock[], options?: SendOptions): void {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
if (this._status !== 'running') return this.send(content, options)
const source = options?.source ?? { kind: 'user' as const }
this.inbox.steer({ content, source })
this.ctx.emit('agent/queued', this, content, { ...options, steering: true })
}
inject(content: ContentBlock[], options?: SendOptions): void {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
const source = options?.source ?? { kind: 'user' as const }
this.session.append('context/message', { content, source })
}
abort(reason?: string): void {
this.currentAbort?.abort(reason ?? 'aborted')
}
/** Start the driver loop. Returns a disposer that stops it. */
start(): () => void {
this.done = runLoop(this.ctx, this, {
setStatus: status => this.setStatus(status),
setAbort: controller => void (this.currentAbort = controller),
disposed: this.disposed,
isDisposed: () => this._status === 'disposed',
})
return () => {
this._status = 'disposed'
this.resolveDisposed()
this.currentAbort?.abort('disposed')
}
}
}

View File

@@ -0,0 +1,57 @@
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
/** One message waiting in an agent's inbox. */
export interface InboxMessage {
content: ContentBlock[]
source: MessageSource
}
/**
* Per-agent inbox: a queued FIFO (drained at turn start) and a steering FIFO
* (drained between steps of a running turn). Purely an in-memory mechanism of
* the loop — the public surface is `Agent.send()` / `Agent.steer()`.
*/
export class Inbox {
private queuedMessages: InboxMessage[] = []
private steeringMessages: InboxMessage[] = []
private wakeup: (() => void) | undefined
/** Resolves when a queued message arrives (used by the idle loop). */
get hasQueued(): boolean {
return this.queuedMessages.length > 0
}
get hasSteering(): boolean {
return this.steeringMessages.length > 0
}
enqueue(message: InboxMessage): void {
this.queuedMessages.push(message)
this.wakeup?.()
}
steer(message: InboxMessage): void {
this.steeringMessages.push(message)
}
/** Drain all queued messages (turn start). */
drainQueued(): InboxMessage[] {
return this.queuedMessages.splice(0)
}
/** Drain all steering messages (between steps). */
drainSteering(): InboxMessage[] {
return this.steeringMessages.splice(0)
}
/** Wait until a queued message arrives or `cancel` resolves. */
waitForQueued(cancel: Promise<void>): Promise<void> {
if (this.hasQueued) return Promise.resolve()
const { promise, resolve } = Promise.withResolvers<void>()
this.wakeup = resolve
void cancel.then(resolve)
return promise.finally(() => {
if (this.wakeup === resolve) this.wakeup = undefined
})
}
}

View File

@@ -0,0 +1,74 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { LoopAgent } from './agent.ts'
export { LoopAgent } from './agent.ts'
export { Inbox, type InboxMessage } from './inbox.ts'
export { runLoop } from './loop.ts'
declare module 'cordis' {
interface Context {
agentLoop: AgentLoop
}
}
export interface Config {
/** Agents created from configuration at startup. */
agents: (AgentOptions & { id: string })[]
}
/**
* The agent-loop plugin (`ctx.agentLoop`): creates {@link LoopAgent}s, runs
* their loops, and registers them in `ctx.agents`.
*
* The loop itself is deliberately thin — every behavior beyond "call the
* model, run the tools, repeat" belongs to plugins listening on the event
* taxonomy declared in @deepseek-ai/dsh-agent.
*/
export class AgentLoop extends Service {
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
static Config: z<Config> = z.object({
agents: z.array(z.object({
id: z.string().required(),
model: z.string(),
systemPrompt: z.string(),
})).default([]),
})
constructor(ctx: Context, public config: Config) {
super(ctx, 'agentLoop')
for (const { id, ...options } of config.agents) {
this.create(id, options)
}
}
/**
* Create an agent, start its loop, and register it. Returns the agent.
* Disposed with the calling fiber.
*
* TODO(sub-agents): spawn/fork land here — accept a parent agent reference;
* fork seeds the new Session with the parent's event log, spawn starts
* fresh; the child is returned as a regular Agent handle.
*/
create(id: string, options: AgentOptions = {}): LoopAgent {
const session = this.ctx.sessions.create(`${id}-session`)
const agent = new LoopAgent(this.ctx, id, options, session)
this.ctx.effect(() => {
const stop = agent.start()
const unregister = this.ctx.agents.register(agent)
return () => {
stop()
unregister()
}
}, 'agentLoop.create()')
return agent
}
}
export default AgentLoop

View File

@@ -0,0 +1,205 @@
import type { Context } from 'cordis'
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
import type { TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type { LoopAgent } from './agent.ts'
export interface LoopHandle {
setStatus(status: 'idle' | 'running'): void
setAbort(controller: AbortController | undefined): void
/** Resolves when the agent is disposed — unblocks the idle wait. */
disposed: Promise<void>
isDisposed(): boolean
}
/**
* The agent loop. One invocation drives one agent for its whole lifetime:
*
* ```
* forever:
* wait for queued messages (idle)
* TURN: drain queued → session('turn/start') → emit agent/turn-start
* STEP loop:
* emit agent/step-start
* assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble
* req = {model, system, tools, messages: session.deriveMessages(), signal}
* req = waterfall agent/request ⟵ hooks/compaction/model-switch
* stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks)
* session('assistant/chunk'); emit agent/stream-chunk; assembler.push
* session('assistant/message','usage')
* msg = waterfall agent/step-result ⟵ post-process before tool dispatch
* each tool-call (sequential):
* session('tool/call'); ctx.tools.execute() ⟵ waterfall tools/execute
* session('tool/result')
* drain steering → session('steering/message'); emit agent/steering
* emit agent/step-end
* cont = waterfall agent/turn-continuation(default = hadToolCalls || steered)
* session('turn/end'); emit agent/turn-end
* await ctx.parallel('session/flush', session) ⟵ durability checkpoint
* ```
*/
export async function runLoop(ctx: Context, agent: LoopAgent, handle: LoopHandle): Promise<void> {
const { session } = agent
while (!handle.isDisposed()) {
await agent.inbox.waitForQueued(handle.disposed)
if (handle.isDisposed()) break
handle.setStatus('running')
const turn = nextTurnNumber(session)
// Drain queued messages into the session — they trigger this turn.
const queued = agent.inbox.drainQueued()
const trigger: TurnTrigger = { kind: 'message', source: queued[0]!.source }
for (const message of queued) {
session.append('user/message', { content: message.content, source: message.source })
}
session.append('turn/start', { turn, trigger })
ctx.emit('agent/turn-start', agent, turn)
let reason: TurnEndReason = { kind: 'completed' }
let step = 0
while (true) {
step += 1
ctx.emit('agent/step-start', agent, turn, step)
session.append('step/start', { turn, step })
const abort = new AbortController()
handle.setAbort(abort)
let stepOutcome: { hadToolCalls: boolean } | { error: Error }
try {
stepOutcome = await runStep(ctx, agent, turn, step, abort.signal)
} catch (error: any) {
stepOutcome = { error: error instanceof Error ? error : new Error(String(error)) }
} finally {
handle.setAbort(undefined)
}
// Steering arrives between steps: drain before deciding continuation
// so the decision (and the next request) sees it.
const steered = agent.inbox.drainSteering()
for (const message of steered) {
session.append('steering/message', { turn, content: message.content, source: message.source })
ctx.emit('agent/steering', agent, turn, message.content)
}
session.append('step/end', { turn, step })
ctx.emit('agent/step-end', agent, turn, step)
if ('error' in stepOutcome) {
const { error } = stepOutcome
if (abort.signal.aborted || handle.isDisposed()) {
reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
} else {
session.append('error', { turn, step, message: error.message, code: (error as any).code })
ctx.emit('agent/error', agent, turn, step, error)
reason = { kind: 'error', message: error.message, code: (error as any).code }
}
break
}
const defaultDecision = stepOutcome.hadToolCalls || steered.length > 0
const shouldContinue = await ctx.waterfall(
'agent/turn-continuation', agent, turn, defaultDecision,
async () => defaultDecision,
)
if (!shouldContinue || handle.isDisposed()) break
}
if (handle.isDisposed() && reason.kind === 'completed') {
reason = { kind: 'disposed' }
}
session.append('turn/end', { turn, reason })
ctx.emit('agent/turn-end', agent, turn, reason)
// Durability checkpoint: persistence plugins drain write-behind buffers.
await ctx.parallel('session/flush', session)
if (!agent.inbox.hasQueued) handle.setStatus('idle')
}
}
/** One step: assemble request → stream model → record → execute tools. */
async function runStep(
ctx: Context,
agent: LoopAgent,
turn: number,
step: number,
signal: AbortSignal,
): Promise<{ hadToolCalls: boolean }> {
const { session, options } = agent
// --- Request assembly ---
const assembly = await ctx.systemPrompt.assemble()
const system = [renderPrompt(assembly), options.systemPrompt ?? '']
.filter(text => text.length > 0)
.join('\n\n')
let request: GenerateOptions = {
model: options.model ?? 'default',
messages: session.deriveMessages(),
system: system || undefined,
tools: assembly.tools.length > 0 ? assembly.tools : undefined,
signal,
}
request = await ctx.waterfall('agent/request', agent, turn, step, request, async () => request)
// --- Model call (streaming-first; raw chunks are the replay record) ---
const assembler = new BlockAssembler()
for await (const chunk of ctx.llm.stream(request)) {
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
session.append('assistant/chunk', { turn, step, chunk })
ctx.emit('agent/stream-chunk', agent, turn, step, chunk)
assembler.push(chunk)
}
let message: Message = assembler.message()
session.append('assistant/message', { turn, step, content: message.content })
if (assembler.usage) {
session.append('usage', { turn, step, usage: assembler.usage })
}
message = await ctx.waterfall('agent/step-result', agent, turn, step, message, async () => message)
// --- Tool execution (sequential; parallel execution is a TODO) ---
const toolCalls = message.content.filter(block => block.type === 'tool-call')
for (const call of toolCalls) {
session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
let parsedArguments: unknown
try {
parsedArguments = call.arguments ? JSON.parse(call.arguments) : {}
} catch {
parsedArguments = call.arguments
}
const result = await ctx.tools.execute({
callId: call.id,
name: call.name,
arguments: parsedArguments,
agent,
signal,
})
session.append('tool/result', {
turn, step,
callId: result.callId,
content: result.content,
isError: result.isError,
})
}
return { hadToolCalls: toolCalls.length > 0 }
}
function nextTurnNumber(session: LoopAgent['session']): number {
for (let index = session.events.length - 1; index >= 0; index--) {
const event = session.events[index]
if (event.type === 'turn/start') {
return (event.data as { turn: number }).turn + 1
}
}
return 1
}