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:
41
packages/agent-loop/package.json
Normal file
41
packages/agent-loop/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-agent-loop",
|
||||
"description": "The concrete agent loop plugin for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
86
packages/agent-loop/src/agent.ts
Normal file
86
packages/agent-loop/src/agent.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
57
packages/agent-loop/src/inbox.ts
Normal file
57
packages/agent-loop/src/inbox.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
74
packages/agent-loop/src/index.ts
Normal file
74
packages/agent-loop/src/index.ts
Normal 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
|
||||
205
packages/agent-loop/src/loop.ts
Normal file
205
packages/agent-loop/src/loop.ts
Normal 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
|
||||
}
|
||||
410
packages/agent-loop/tests/loop.spec.ts
Normal file
410
packages/agent-loop/tests/loop.spec.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionEventType } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the agent's NEXT transition to idle. Always event-based: callers
|
||||
* invoke this right after send(), when the loop hasn't woken yet (status is
|
||||
* still 'idle' synchronously), so polling the current status would lie.
|
||||
*/
|
||||
function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function send(agent: LoopAgent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
describe('agent loop', () => {
|
||||
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hello there')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const order: string[] = []
|
||||
for (const name of ['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end'] as const) {
|
||||
ctx.on(name, () => void order.push(name))
|
||||
}
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(order).toEqual(['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end'])
|
||||
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
// user message recorded before turn/start, assembled message + usage present
|
||||
expect(types[0]).toBe('user/message')
|
||||
expect(types[1]).toBe('turn/start')
|
||||
expect(types).toContain('assistant/message')
|
||||
expect(types).toContain('usage')
|
||||
expect(types.at(-1)).toBe('turn/end')
|
||||
|
||||
// derived history: user + assistant
|
||||
const messages = agent.session.deriveMessages()
|
||||
expect(messages.map(m => m.role)).toEqual(['user', 'assistant'])
|
||||
expect(messages[1].content).toEqual([{ type: 'text', text: 'hello there' }])
|
||||
})
|
||||
|
||||
it('round-trips tool calls: model requests tool → executes → result in next request', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'ping' }, 'calling echo'),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register({
|
||||
name: 'echo',
|
||||
description: 'echo back',
|
||||
parameters: { type: 'object' },
|
||||
async execute(args: any) {
|
||||
return [{ type: 'text', text: `echo: ${args.text}` }]
|
||||
},
|
||||
})
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
send(agent, 'use the tool')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// two model calls happened (tool-call step, then final step)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
|
||||
// the second request's derived history contains the tool result
|
||||
const secondMessages = adapter.requests[1].messages
|
||||
const toolResultMessage = secondMessages.find(m =>
|
||||
m.content.some(b => b.type === 'tool-result'))
|
||||
expect(toolResultMessage).toBeDefined()
|
||||
const block = toolResultMessage!.content.find(b => b.type === 'tool-result')!
|
||||
expect(block).toMatchObject({ toolCallId: 'c1', isError: false })
|
||||
expect((block as any).content).toEqual([{ type: 'text', text: 'echo: ping' }])
|
||||
|
||||
// session log records call + result
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types).toContain('tool/call')
|
||||
expect(types).toContain('tool/result')
|
||||
})
|
||||
|
||||
it('passes assembled system prompt and tool schemas into the request', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You are a test agent.' })
|
||||
ctx.tools.register({
|
||||
name: 'noop',
|
||||
description: 'does nothing',
|
||||
parameters: { type: 'object' },
|
||||
async execute() {
|
||||
return []
|
||||
},
|
||||
})
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock', systemPrompt: 'Agent-specific suffix.' })
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const request = adapter.requests[0]
|
||||
expect(request.system).toBe('You are a test agent.\n\nAgent-specific suffix.')
|
||||
expect(request.tools?.map(t => t.name)).toEqual(['noop'])
|
||||
})
|
||||
|
||||
it('records raw chunks for replay and emits agent/stream-chunk', async () => {
|
||||
const adapter = new MockAdapter([textResponse('abc')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const streamed: StreamChunk[] = []
|
||||
ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => void streamed.push(chunk))
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const chunkEvents = agent.session.events.filter(e => e.type === 'assistant/chunk')
|
||||
// textResponse('abc') = block-start + 3 deltas + block-end + usage + finish = 7
|
||||
expect(chunkEvents).toHaveLength(7)
|
||||
expect(streamed).toHaveLength(7)
|
||||
// replay: chunk events alone re-assemble to the recorded assistant message
|
||||
const deltaText = chunkEvents
|
||||
.map(e => (e.data as any).chunk)
|
||||
.filter((c: StreamChunk) => c.type === 'text-delta')
|
||||
.map((c: any) => c.text)
|
||||
.join('')
|
||||
expect(deltaText).toBe('abc')
|
||||
})
|
||||
|
||||
it('injects steering between steps and continues the turn', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'slow', {}),
|
||||
textResponse('addressed the steering'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
ctx.tools.register({
|
||||
name: 'slow',
|
||||
description: '',
|
||||
parameters: { type: 'object' },
|
||||
async execute() {
|
||||
// steer while the turn is running (during tool execution)
|
||||
agent.steer([{ type: 'text', text: 'change of plans' }])
|
||||
return [{ type: 'text', text: 'tool done' }]
|
||||
},
|
||||
})
|
||||
|
||||
send(agent, 'start')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types).toContain('steering/message')
|
||||
// steering recorded before the second step's request derived its history
|
||||
const steeringSeq = agent.session.events.find(e => e.type === 'steering/message')!.seq
|
||||
const secondStepStart = agent.session.events.filter(e => e.type === 'step/start')[1]
|
||||
expect(secondStepStart).toBeDefined()
|
||||
expect(steeringSeq).toBeLessThan(secondStepStart!.seq)
|
||||
|
||||
// the second model request saw the steering content
|
||||
const secondRequest = adapter.requests[1]
|
||||
const flat = JSON.stringify(secondRequest.messages)
|
||||
expect(flat).toContain('change of plans')
|
||||
})
|
||||
|
||||
it('steering while idle behaves like send (starts a turn)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
agent.steer([{ type: 'text', text: 'hello' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true)
|
||||
})
|
||||
|
||||
it('inject() appends context visible to the next request without starting a turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } })
|
||||
// no turn started
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
const flat = JSON.stringify(adapter.requests[0].messages)
|
||||
expect(flat).toContain('file changed: a.ts')
|
||||
expect(flat).toContain('<context source=\\"plugin\\">')
|
||||
})
|
||||
|
||||
it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => {
|
||||
// force-continue: model never calls tools, but a plugin forces 3 steps
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('step 1'),
|
||||
textResponse('step 2'),
|
||||
textResponse('step 3'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
let steps = 0
|
||||
ctx.on('agent/step-end', () => void steps++)
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
|
||||
if (steps < 3) return true
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(steps).toBe(3)
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('agent/turn-continuation can veto continuation despite tool calls (budget-guard pattern)', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'x' })])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { type: 'object' },
|
||||
async execute(args: any) {
|
||||
return [{ type: 'text', text: String(args.text) }]
|
||||
},
|
||||
})
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
ctx.on('agent/turn-continuation', async () => false as const)
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
// only one model call despite the tool call requesting a follow-up
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
// tool still executed before the decision
|
||||
expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true)
|
||||
})
|
||||
|
||||
it('agent/request waterfall can rewrite the request (model-switch pattern)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.llm.registerAdapter(['other-model'], adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, options, next) => {
|
||||
options.model = 'other-model'
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests[0].model).toBe('other-model')
|
||||
})
|
||||
|
||||
it('abort() mid-stream ends the turn with reason aborted', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const reasons: any[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'go')
|
||||
// wait until the stream is hanging, then abort
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
agent.abort('user interrupt')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
|
||||
})
|
||||
|
||||
it('chains queued messages into consecutive turns', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const turns: number[] = []
|
||||
ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn))
|
||||
|
||||
// queue two messages while idle — first starts turn 1 immediately;
|
||||
// queue the second during turn 1 via a stream-chunk hook
|
||||
let queued = false
|
||||
ctx.on('agent/stream-chunk', () => {
|
||||
if (!queued) {
|
||||
queued = true
|
||||
send(agent, 'second message')
|
||||
}
|
||||
})
|
||||
|
||||
send(agent, 'first message')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(turns).toEqual([1, 2])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('awaits session/flush at turn end (persistence checkpoint)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
let flushed = 0
|
||||
let flushedBeforeIdle = false
|
||||
ctx.on('session/flush', async (session) => {
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
flushed++
|
||||
flushedBeforeIdle = agent.status !== 'idle'
|
||||
void session
|
||||
})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(flushed).toBe(1)
|
||||
expect(flushedBeforeIdle).toBe(true)
|
||||
})
|
||||
|
||||
it('errors from the model surface as agent/error and end the turn', async () => {
|
||||
const adapter = new MockAdapter([]) // script exhausted → throws
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const errors: Error[] = []
|
||||
const reasons: any[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0].message).toContain('script exhausted')
|
||||
expect(reasons[0]).toMatchObject({ kind: 'error' })
|
||||
expect(agent.session.events.some(e => e.type === 'error')).toBe(true)
|
||||
})
|
||||
|
||||
it('disposing the loop fiber mid-turn stops the loop (HMR safety)', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
let agent!: LoopAgent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create('scoped', { model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
expect(ctx.agents.get('scoped')).toBe(agent)
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
|
||||
expect(agent.status).toBe('disposed')
|
||||
expect(ctx.agents.get('scoped')).toBeUndefined()
|
||||
expect(() => send(agent, 'too late')).toThrow('disposed')
|
||||
})
|
||||
|
||||
it('replays a session log into an identical derived history', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'x' }),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { type: 'object' },
|
||||
async execute(args: any) {
|
||||
return [{ type: 'text', text: String(args.text) }]
|
||||
},
|
||||
})
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
send(agent, 'run')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const replayed = ctx.sessions.create('replayed', [...agent.session.events])
|
||||
expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
|
||||
// event-by-event identity of types
|
||||
expect(replayed.events.map(e => e.type)).toEqual(
|
||||
agent.session.events.map(e => e.type as SessionEventType))
|
||||
})
|
||||
})
|
||||
74
packages/agent-loop/tests/mock-adapter.ts
Normal file
74
packages/agent-loop/tests/mock-adapter.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Helpers to write scripted responses tersely. */
|
||||
export function textResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...[...text].map((char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
}
|
||||
|
||||
export function toolCallResponse(callId: string, name: string, args: object, text?: string): StreamChunk[] {
|
||||
const argumentsJson = JSON.stringify(args)
|
||||
const chunks: StreamChunk[] = []
|
||||
let index = 0
|
||||
if (text) {
|
||||
chunks.push(
|
||||
{ type: 'block-start', index, blockType: 'text' },
|
||||
{ type: 'text-delta', index, text },
|
||||
{ type: 'block-end', index, block: { type: 'text', text } },
|
||||
)
|
||||
index += 1
|
||||
}
|
||||
chunks.push(
|
||||
{ type: 'block-start', index, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index, id: callId, name, argumentsDelta: argumentsJson.slice(0, 5) },
|
||||
{ type: 'tool-call-delta', index, id: callId, argumentsDelta: argumentsJson.slice(5) },
|
||||
{
|
||||
type: 'block-end',
|
||||
index,
|
||||
block: { type: 'tool-call', id: callId, name, arguments: argumentsJson },
|
||||
},
|
||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
)
|
||||
return chunks
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock adapter driven by a script: each model call consumes the next entry.
|
||||
* Records every request it receives for assertions. An entry may be a
|
||||
* function to compute chunks from the request, or a 'hang' marker that
|
||||
* streams one chunk then waits until aborted.
|
||||
*/
|
||||
export class MockAdapter extends LlmAdapter {
|
||||
requests: GenerateOptions[] = []
|
||||
|
||||
constructor(private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const entry = this.script.shift()
|
||||
if (!entry) throw new Error('MockAdapter: script exhausted')
|
||||
if (entry === 'hang') {
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: 'partial' }
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (options.signal?.aborted) return reject(new Error('aborted'))
|
||||
options.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
|
||||
})
|
||||
return
|
||||
}
|
||||
const chunks = typeof entry === 'function' ? entry(options) : entry
|
||||
for (const chunk of chunks) {
|
||||
if (options.signal?.aborted) throw new Error('aborted')
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
18
packages/agent-loop/tsconfig.json
Normal file
18
packages/agent-loop/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../vendor/cosmokit" },
|
||||
{ "path": "../../vendor/cordis" },
|
||||
{ "path": "../../vendor/schemastery" },
|
||||
{ "path": "../llm" },
|
||||
{ "path": "../session" },
|
||||
{ "path": "../system-prompt" },
|
||||
{ "path": "../tools" },
|
||||
{ "path": "../agent" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user