Skip to content

Commit 92f701b

Browse files
committed
feat(agent): add pausable tools and run store
1 parent 536d30e commit 92f701b

6 files changed

Lines changed: 597 additions & 71 deletions

File tree

packages/agent/__tests__/agent.test.ts

Lines changed: 210 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,15 @@ import {
1010
makeFakeModel,
1111
} from '@test/index';
1212

13-
import { Agent } from '../src';
13+
import {
14+
Agent,
15+
type AgentEvent,
16+
type AgentTool,
17+
DecisionValidationError,
18+
MemoryRunStore,
19+
RunNotFoundError,
20+
ToolNotRegisteredError,
21+
} from '../src';
1422

1523
describe('@agentic-kit/agent', () => {
1624
it('runs a minimal sequential tool loop', async () => {
@@ -47,7 +55,7 @@ describe('@agentic-kit/agent', () => {
4755
},
4856
required: ['text'],
4957
},
50-
execute: async (_toolCallId, params) => ({
58+
execute: async (_toolCallId, params, _decision) => ({
5159
content: [{ type: 'text', text: String(params.text) }],
5260
}),
5361
},
@@ -184,3 +192,203 @@ function makeUsage() {
184192
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
185193
};
186194
}
195+
196+
describe('@agentic-kit/agent — pausable tools', () => {
197+
function makeApprovalTool(execute: AgentTool['execute']): AgentTool {
198+
return {
199+
name: 'approve',
200+
label: 'Approve',
201+
description: 'Tool that requires explicit approval',
202+
parameters: {
203+
type: 'object',
204+
properties: { target: { type: 'string' } },
205+
required: ['target'],
206+
},
207+
decision: {
208+
type: 'object',
209+
properties: { approved: { type: 'boolean' } },
210+
required: ['approved'],
211+
},
212+
execute,
213+
};
214+
}
215+
216+
function pauseResponse() {
217+
return makeFakeAssistantMessage({
218+
stopReason: 'toolUse',
219+
content: [
220+
{ type: 'toolCall', id: 'tool_1', name: 'approve', arguments: { target: 'thing' } },
221+
],
222+
});
223+
}
224+
225+
function finalResponse() {
226+
return makeFakeAssistantMessage({
227+
stopReason: 'stop',
228+
content: [{ type: 'text', text: 'finalized' }],
229+
});
230+
}
231+
232+
it('pauses on a decision-bearing tool, persists the run, and emits tool_decision_pending', async () => {
233+
const provider = createScriptedProvider({ responses: [pauseResponse()] });
234+
const runStore = new MemoryRunStore();
235+
const saveSpy = jest.spyOn(runStore, 'save');
236+
const execute = jest.fn();
237+
const events: AgentEvent[] = [];
238+
239+
const agent = new Agent({
240+
initialState: { model: makeFakeModel() },
241+
streamFn: provider.stream,
242+
runStore,
243+
});
244+
agent.subscribe((event) => events.push(event));
245+
agent.setTools([makeApprovalTool(execute)]);
246+
247+
await agent.prompt('approve thing');
248+
249+
expect(execute).not.toHaveBeenCalled();
250+
expect(saveSpy).toHaveBeenCalledTimes(1);
251+
252+
const pendingEvent = events.find((e) => e.type === 'tool_decision_pending');
253+
expect(pendingEvent).toMatchObject({
254+
type: 'tool_decision_pending',
255+
toolCallId: 'tool_1',
256+
toolName: 'approve',
257+
input: { target: 'thing' },
258+
schema: expect.objectContaining({ type: 'object' }),
259+
});
260+
261+
const runId = (pendingEvent as { runId: string }).runId;
262+
expect(runId).toBeTruthy();
263+
expect(agent.pendingRunId).toBe(runId);
264+
expect(agent.state.isStreaming).toBe(false);
265+
266+
expect(events.some((e) => e.type === 'agent_end')).toBe(false);
267+
268+
const stored = await runStore.load(runId);
269+
expect(stored).toMatchObject({
270+
id: runId,
271+
pending: { toolCallId: 'tool_1', toolName: 'approve', input: { target: 'thing' } },
272+
});
273+
expect(stored?.tools[0]).not.toHaveProperty('execute');
274+
});
275+
276+
it('resume invokes execute with the decision argument and continues the loop', async () => {
277+
const provider = createScriptedProvider({ responses: [pauseResponse(), finalResponse()] });
278+
const execute = jest.fn(
279+
async (_id: string, _params: Record<string, unknown>, decision: unknown) => ({
280+
content: [{ type: 'text' as const, text: `decision=${JSON.stringify(decision)}` }],
281+
})
282+
);
283+
const events: AgentEvent[] = [];
284+
285+
const agent = new Agent({
286+
initialState: { model: makeFakeModel() },
287+
streamFn: provider.stream,
288+
});
289+
agent.subscribe((event) => events.push(event));
290+
agent.setTools([makeApprovalTool(execute)]);
291+
292+
await agent.prompt('approve thing');
293+
const runId = agent.pendingRunId!;
294+
expect(runId).toBeTruthy();
295+
296+
await agent.resume(runId, { approved: true });
297+
298+
expect(execute).toHaveBeenCalledTimes(1);
299+
expect(execute.mock.calls[0]?.[2]).toEqual({ approved: true });
300+
expect(agent.pendingRunId).toBeUndefined();
301+
302+
expect(agent.state.messages.at(-1)).toMatchObject({
303+
role: 'assistant',
304+
content: [{ type: 'text', text: 'finalized' }],
305+
});
306+
expect(events.some((e) => e.type === 'agent_end')).toBe(true);
307+
});
308+
309+
it('rejects a malformed decision and leaves the run resumable', async () => {
310+
const provider = createScriptedProvider({ responses: [pauseResponse(), finalResponse()] });
311+
const runStore = new MemoryRunStore();
312+
const execute = jest.fn(
313+
async (_id: string, _params: Record<string, unknown>, decision: unknown) => ({
314+
content: [{ type: 'text' as const, text: `decision=${JSON.stringify(decision)}` }],
315+
})
316+
);
317+
318+
const agent = new Agent({
319+
initialState: { model: makeFakeModel() },
320+
streamFn: provider.stream,
321+
runStore,
322+
});
323+
agent.setTools([makeApprovalTool(execute)]);
324+
325+
await agent.prompt('approve thing');
326+
const runId = agent.pendingRunId!;
327+
328+
await expect(agent.resume(runId, { approved: 'yes' })).rejects.toBeInstanceOf(
329+
DecisionValidationError
330+
);
331+
expect(execute).not.toHaveBeenCalled();
332+
expect(agent.pendingRunId).toBe(runId);
333+
expect(await runStore.load(runId)).toBeDefined();
334+
335+
await agent.resume(runId, { approved: true });
336+
337+
expect(execute).toHaveBeenCalledTimes(1);
338+
expect(agent.pendingRunId).toBeUndefined();
339+
expect(await runStore.load(runId)).toBeUndefined();
340+
});
341+
342+
it('throws RunNotFoundError when resuming an unknown run', async () => {
343+
const agent = new Agent({
344+
initialState: { model: makeFakeModel() },
345+
streamFn: createScriptedProvider({ responses: [] }).stream,
346+
});
347+
348+
await expect(agent.resume('does-not-exist', { approved: true })).rejects.toBeInstanceOf(
349+
RunNotFoundError
350+
);
351+
});
352+
353+
it('cleans up the persisted run when abort() is called while paused', async () => {
354+
const provider = createScriptedProvider({ responses: [pauseResponse()] });
355+
const runStore = new MemoryRunStore();
356+
357+
const agent = new Agent({
358+
initialState: { model: makeFakeModel() },
359+
streamFn: provider.stream,
360+
runStore,
361+
});
362+
agent.setTools([makeApprovalTool(jest.fn())]);
363+
364+
await agent.prompt('approve thing');
365+
const runId = agent.pendingRunId!;
366+
expect(await runStore.load(runId)).toBeDefined();
367+
368+
agent.abort();
369+
await new Promise((resolve) => setImmediate(resolve));
370+
371+
expect(agent.pendingRunId).toBeUndefined();
372+
expect(await runStore.load(runId)).toBeUndefined();
373+
});
374+
375+
it('throws ToolNotRegisteredError when resuming after the tool has been removed', async () => {
376+
const provider = createScriptedProvider({ responses: [pauseResponse(), finalResponse()] });
377+
const tool = makeApprovalTool(jest.fn());
378+
379+
const agent = new Agent({
380+
initialState: { model: makeFakeModel() },
381+
streamFn: provider.stream,
382+
});
383+
agent.setTools([tool]);
384+
385+
await agent.prompt('approve thing');
386+
const runId = agent.pendingRunId!;
387+
388+
agent.setTools([]);
389+
390+
await expect(agent.resume(runId, { approved: true })).rejects.toBeInstanceOf(
391+
ToolNotRegisteredError
392+
);
393+
});
394+
});

0 commit comments

Comments
 (0)