diff --git a/packages/agent-loop/package.json b/packages/agent-loop/package.json new file mode 100644 index 0000000000..8557ba85fa --- /dev/null +++ b/packages/agent-loop/package.json @@ -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" + } +} diff --git a/packages/agent-loop/src/agent.ts b/packages/agent-loop/src/agent.ts new file mode 100644 index 0000000000..79e269fc4c --- /dev/null +++ b/packages/agent-loop/src/agent.ts @@ -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 + private resolveDisposed!: () => void + /** Resolves when the driver loop has fully exited (tests/disposal). */ + done: Promise = Promise.resolve() + + constructor( + private ctx: Context, + public readonly id: string, + public readonly options: AgentOptions, + public readonly session: Session, + ) { + const { promise, resolve } = Promise.withResolvers() + 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') + } + } +} diff --git a/packages/agent-loop/src/inbox.ts b/packages/agent-loop/src/inbox.ts new file mode 100644 index 0000000000..483a2a62de --- /dev/null +++ b/packages/agent-loop/src/inbox.ts @@ -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): Promise { + if (this.hasQueued) return Promise.resolve() + const { promise, resolve } = Promise.withResolvers() + this.wakeup = resolve + void cancel.then(resolve) + return promise.finally(() => { + if (this.wakeup === resolve) this.wakeup = undefined + }) + } +} diff --git a/packages/agent-loop/src/index.ts b/packages/agent-loop/src/index.ts new file mode 100644 index 0000000000..1098420725 --- /dev/null +++ b/packages/agent-loop/src/index.ts @@ -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 = 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 diff --git a/packages/agent-loop/src/loop.ts b/packages/agent-loop/src/loop.ts new file mode 100644 index 0000000000..1f045e21b1 --- /dev/null +++ b/packages/agent-loop/src/loop.ts @@ -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 + 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 { + 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 +} diff --git a/packages/agent-loop/tests/loop.spec.ts b/packages/agent-loop/tests/loop.spec.ts new file mode 100644 index 0000000000..98483048b0 --- /dev/null +++ b/packages/agent-loop/tests/loop.spec.ts @@ -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 { + 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('') + }) + + 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)) + }) +}) diff --git a/packages/agent-loop/tests/mock-adapter.ts b/packages/agent-loop/tests/mock-adapter.ts new file mode 100644 index 0000000000..b2acce61bf --- /dev/null +++ b/packages/agent-loop/tests/mock-adapter.ts @@ -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 { + 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((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 + } + } +} diff --git a/packages/agent-loop/tsconfig.json b/packages/agent-loop/tsconfig.json new file mode 100644 index 0000000000..4fdd8dc398 --- /dev/null +++ b/packages/agent-loop/tsconfig.json @@ -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" } + ] +}