Skip to content

Commit a0155e4

Browse files
committed
feat(agent): maxSteps + lookup decision by id
1 parent 4b26cbd commit a0155e4

4 files changed

Lines changed: 213 additions & 22 deletions

File tree

apps/nextjs-chat-demo/src/app/api/chat/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export async function POST(req: Request): Promise<Response> {
6868
const agent = new Agent({
6969
initialState: { model, tools, systemPrompt: SYSTEM_PROMPT },
7070
streamFn: (m, ctx, opts) => adapter.stream(m, ctx, opts),
71+
maxSteps: 5,
7172
});
7273

7374
const isResume = lastMessageHasPendingDecision(messages);

packages/agent/__tests__/agent.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
type AssistantMessage,
33
type Context,
44
createAssistantMessageEventStream,
5+
type Message,
56
type ModelDescriptor,
67
type ToolCallContent,
78
} from 'agentic-kit';
@@ -346,6 +347,41 @@ describe('@agentic-kit/agent — pausable tools', () => {
346347
expect(() => agent.continue()).toThrow(/no tool calls awaiting a decision/);
347348
});
348349

350+
it('continue() resumes from a non-trailing assistant when a later message was appended after the pause', async () => {
351+
const provider = createScriptedProvider({ responses: [pauseResponse(), finalResponse()] });
352+
const execute = jest.fn(
353+
async (_id: string, _params: Record<string, unknown>, decision: unknown) => ({
354+
content: [{ type: 'text' as const, text: `decision=${JSON.stringify(decision)}` }],
355+
})
356+
);
357+
358+
const agent = new Agent({
359+
initialState: { model: makeFakeModel() },
360+
streamFn: provider.stream,
361+
});
362+
agent.setTools([makeApprovalTool(execute)]);
363+
364+
await agent.prompt('approve thing');
365+
366+
attachDecision(agent, 'tool_1', { approved: true });
367+
368+
const trailingNote: Message = {
369+
role: 'user',
370+
content: 'side note injected by an external queue while paused',
371+
timestamp: Date.now(),
372+
};
373+
agent.replaceMessages([...agent.state.messages, trailingNote]);
374+
375+
await agent.continue();
376+
377+
expect(execute).toHaveBeenCalledTimes(1);
378+
expect(execute.mock.calls[0]?.[2]).toEqual({ approved: true });
379+
expect(agent.state.messages.at(-1)).toMatchObject({
380+
role: 'assistant',
381+
content: [{ type: 'text', text: 'finalized' }],
382+
});
383+
});
384+
349385
it('abort() while paused stops further work without throwing', async () => {
350386
const provider = createScriptedProvider({ responses: [pauseResponse()] });
351387

@@ -480,3 +516,124 @@ describe('@agentic-kit/agent — pausable tools', () => {
480516
expect(events.some((e) => e.type === 'agent_end')).toBe(true);
481517
});
482518
});
519+
520+
describe('@agentic-kit/agent — maxSteps', () => {
521+
function makeEchoTool(): AgentTool {
522+
return {
523+
name: 'echo',
524+
label: 'Echo',
525+
description: 'Echo text',
526+
parameters: {
527+
type: 'object',
528+
properties: { text: { type: 'string' } },
529+
required: ['text'],
530+
},
531+
execute: async (_id, params) => ({
532+
content: [{ type: 'text', text: String(params.text) }],
533+
}),
534+
};
535+
}
536+
537+
function toolThenText(toolText = 'one', finalText = 'done') {
538+
return [
539+
makeFakeAssistantMessage({
540+
stopReason: 'toolUse',
541+
content: [
542+
{ type: 'toolCall', id: 'tool_1', name: 'echo', arguments: { text: toolText } },
543+
],
544+
}),
545+
makeFakeAssistantMessage({
546+
stopReason: 'stop',
547+
content: [{ type: 'text', text: finalText }],
548+
}),
549+
];
550+
}
551+
552+
it('halts after the configured number of model calls and emits agent_end with stopReason=max_steps', async () => {
553+
const provider = createScriptedProvider({ responses: toolThenText() });
554+
const agent = new Agent({
555+
initialState: { model: makeFakeModel() },
556+
streamFn: provider.stream,
557+
maxSteps: 1,
558+
});
559+
agent.setTools([makeEchoTool()]);
560+
561+
const events: AgentEvent[] = [];
562+
agent.subscribe((e) => events.push(e));
563+
564+
await agent.prompt('go');
565+
566+
expect(agent.state.stepCount).toBe(1);
567+
// Tool ran for the first turn, but no second model call.
568+
const toolResults = agent.state.messages.filter((m) => m.role === 'toolResult');
569+
expect(toolResults).toHaveLength(1);
570+
const assistants = agent.state.messages.filter((m) => m.role === 'assistant');
571+
expect(assistants).toHaveLength(1);
572+
573+
const end = events.find((e) => e.type === 'agent_end');
574+
expect(end).toMatchObject({ type: 'agent_end', stopReason: 'max_steps' });
575+
});
576+
577+
it('does not enforce a cap when maxSteps is undefined (no behavior change)', async () => {
578+
const provider = createScriptedProvider({ responses: toolThenText() });
579+
const agent = new Agent({
580+
initialState: { model: makeFakeModel() },
581+
streamFn: provider.stream,
582+
});
583+
agent.setTools([makeEchoTool()]);
584+
585+
const events: AgentEvent[] = [];
586+
agent.subscribe((e) => events.push(e));
587+
588+
await agent.prompt('go');
589+
590+
expect(agent.state.stepCount).toBe(2);
591+
expect(agent.state.messages.at(-1)).toMatchObject({
592+
role: 'assistant',
593+
content: [{ type: 'text', text: 'done' }],
594+
});
595+
const end = events.find((e) => e.type === 'agent_end');
596+
expect(end).toMatchObject({ stopReason: 'completed' });
597+
});
598+
599+
it('per-call maxSteps overrides the constructor default', async () => {
600+
const provider = createScriptedProvider({ responses: toolThenText() });
601+
const agent = new Agent({
602+
initialState: { model: makeFakeModel() },
603+
streamFn: provider.stream,
604+
maxSteps: 1, // would cap; per-call override allows the second call
605+
});
606+
agent.setTools([makeEchoTool()]);
607+
608+
await agent.prompt('go', { maxSteps: 5 });
609+
610+
expect(agent.state.stepCount).toBe(2);
611+
expect(agent.state.messages.at(-1)).toMatchObject({
612+
role: 'assistant',
613+
content: [{ type: 'text', text: 'done' }],
614+
});
615+
});
616+
617+
it('prompt() resets stepCount; continue() preserves it across turns', async () => {
618+
// Two prompt rounds: first one consumes 2 steps; second prompt resets to 0.
619+
const responses = [
620+
...toolThenText('first', 'first-done'),
621+
makeFakeAssistantMessage({
622+
stopReason: 'stop',
623+
content: [{ type: 'text', text: 'second-done' }],
624+
}),
625+
];
626+
const provider = createScriptedProvider({ responses });
627+
const agent = new Agent({
628+
initialState: { model: makeFakeModel() },
629+
streamFn: provider.stream,
630+
});
631+
agent.setTools([makeEchoTool()]);
632+
633+
await agent.prompt('first');
634+
expect(agent.state.stepCount).toBe(2);
635+
636+
await agent.prompt('second');
637+
expect(agent.state.stepCount).toBe(1);
638+
});
639+
});

packages/agent/src/agent.ts

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export class Agent {
3232
private readonly transformContext?: AgentOptions['transformContext'];
3333
private readonly streamFn: NonNullable<AgentOptions['streamFn']>;
3434
private readonly validateToolArguments: NonNullable<AgentOptions['validateToolArguments']>;
35+
private readonly defaultMaxSteps?: number;
3536
private abortController?: AbortController;
3637
private running?: Promise<void>;
3738
private runChannel?: { push: RunChannelPush };
@@ -44,13 +45,15 @@ export class Agent {
4445
tools: [],
4546
messages: [],
4647
isStreaming: false,
48+
stepCount: 0,
4749
streamMessage: null,
4850
streamOptions: undefined,
4951
...options.initialState,
5052
};
5153
this.streamFn = options.streamFn ?? stream;
5254
this.transformContext = options.transformContext;
5355
this.validateToolArguments = options.validateToolArguments ?? defaultValidateToolArguments;
56+
this.defaultMaxSteps = options.maxSteps;
5457
}
5558

5659
get state(): AgentState {
@@ -106,55 +109,70 @@ export class Agent {
106109
return this.running ?? Promise.resolve();
107110
}
108111

109-
prompt(input: string | Message): AgentRunHandle {
112+
prompt(input: string | Message, opts?: { maxSteps?: number }): AgentRunHandle {
110113
if (this._state.isStreaming) {
111114
throw new Error('Agent is already processing a prompt');
112115
}
113116

114117
const message = typeof input === 'string' ? createUserMessage(input) : input;
118+
this._state.stepCount = 0;
115119

116120
return new DefaultAgentRunHandle(async (push, signal) =>
117121
this.runLoop({
118122
initialMessages: [message],
119123
externalPush: push ?? undefined,
120124
externalAbortSignal: signal,
125+
maxSteps: opts?.maxSteps ?? this.defaultMaxSteps,
121126
})
122127
);
123128
}
124129

125-
continue(): AgentRunHandle {
130+
continue(opts?: { maxSteps?: number }): AgentRunHandle {
126131
if (this._state.isStreaming) {
127132
throw new Error('Agent is already processing');
128133
}
129134

130-
const lastMessage = this._state.messages[this._state.messages.length - 1];
131-
if (!lastMessage) {
135+
if (this._state.messages.length === 0) {
132136
throw new Error('No messages to continue from');
133137
}
134138

135-
if (lastMessage.role === 'assistant') {
136-
const pendingDecisions = this.findPendingDecisions(lastMessage);
137-
if (pendingDecisions.length === 0) {
138-
throw new Error(
139-
'Cannot continue from trailing assistant message: no tool calls awaiting a decision'
140-
);
141-
}
139+
const pendingMessage = this.findMostRecentPendingAssistant();
140+
if (pendingMessage) {
141+
const pendingDecisions = this.findPendingDecisions(pendingMessage);
142142
for (const { tool, decision } of pendingDecisions) {
143143
const errors = validateSchema(tool.decision!, decision, 'root');
144144
if (errors.length > 0) {
145145
throw new DecisionValidationError(tool.name, errors);
146146
}
147147
}
148+
} else {
149+
const lastMessage = this._state.messages[this._state.messages.length - 1];
150+
if (lastMessage.role === 'assistant') {
151+
throw new Error(
152+
'Cannot continue from trailing assistant message: no tool calls awaiting a decision'
153+
);
154+
}
148155
}
149156

150157
return new DefaultAgentRunHandle(async (push, signal) =>
151158
this.runLoop({
152159
externalPush: push ?? undefined,
153160
externalAbortSignal: signal,
161+
maxSteps: opts?.maxSteps ?? this.defaultMaxSteps,
154162
})
155163
);
156164
}
157165

166+
private findMostRecentPendingAssistant(): AssistantMessage | undefined {
167+
for (let i = this._state.messages.length - 1; i >= 0; i--) {
168+
const msg = this._state.messages[i];
169+
if (msg.role !== 'assistant') continue;
170+
const pending = this.findPendingDecisions(msg);
171+
if (pending.length > 0) return msg;
172+
}
173+
return undefined;
174+
}
175+
158176
private findPendingDecisions(
159177
message: AssistantMessage
160178
): Array<{ toolCall: ToolCallContent; tool: AgentTool; decision: unknown }> {
@@ -188,6 +206,7 @@ export class Agent {
188206
initialMessages?: Message[];
189207
externalPush?: RunChannelPush;
190208
externalAbortSignal?: AbortSignal;
209+
maxSteps?: number;
191210
}): Promise<void> {
192211
this.running = (async () => {
193212
this.abortController = new AbortController();
@@ -208,6 +227,8 @@ export class Agent {
208227
}
209228
}
210229

230+
let stopReason: 'completed' | 'max_steps' = 'completed';
231+
211232
try {
212233
await this.emit({ type: 'agent_start' });
213234

@@ -219,20 +240,25 @@ export class Agent {
219240
}
220241
}
221242

222-
let resumingFromTrailingAssistant =
223-
this._state.messages[this._state.messages.length - 1]?.role === 'assistant';
243+
let resumeAssistant: AssistantMessage | undefined =
244+
this.findMostRecentPendingAssistant();
224245

225246
while (true) {
226247
let assistantMessage: AssistantMessage;
227248

228-
if (resumingFromTrailingAssistant) {
229-
const last = this._state.messages[this._state.messages.length - 1];
230-
if (!last || last.role !== 'assistant') {
231-
throw new Error('Cannot resume: last message is not an assistant message');
232-
}
233-
assistantMessage = last;
234-
resumingFromTrailingAssistant = false;
249+
if (resumeAssistant) {
250+
assistantMessage = resumeAssistant;
251+
resumeAssistant = undefined;
235252
} else {
253+
if (
254+
opts.maxSteps !== undefined &&
255+
this._state.stepCount >= opts.maxSteps
256+
) {
257+
stopReason = 'max_steps';
258+
break;
259+
}
260+
this._state.stepCount += 1;
261+
236262
await this.emit({ type: 'turn_start' });
237263
assistantMessage = await this.generateAssistantMessage(localAbortController.signal);
238264
this.appendMessage(assistantMessage);
@@ -262,7 +288,7 @@ export class Agent {
262288
await this.emit({ type: 'turn_end', message: assistantMessage, toolResults: outcome.results });
263289
}
264290

265-
await this.emit({ type: 'agent_end', messages: [...this._state.messages] });
291+
await this.emit({ type: 'agent_end', messages: [...this._state.messages], stopReason });
266292
} finally {
267293
if (opts.externalAbortSignal) {
268294
opts.externalAbortSignal.removeEventListener('abort', onExternalAbort);

packages/agent/src/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface AgentState {
3636
isStreaming: boolean;
3737
messages: Message[];
3838
model: ModelDescriptor;
39+
stepCount: number;
3940
streamMessage: AssistantMessage | null;
4041
streamOptions?: Omit<StreamOptions, 'signal'>;
4142
systemPrompt: string;
@@ -48,7 +49,7 @@ export interface AgentEventBase {
4849

4950
export type AgentEvent =
5051
| { type: 'agent_start' }
51-
| { type: 'agent_end'; messages: Message[] }
52+
| { type: 'agent_end'; messages: Message[]; stopReason?: 'completed' | 'max_steps' }
5253
| { type: 'turn_start' }
5354
| { type: 'turn_end'; message: AssistantMessage; toolResults: ToolResultMessage[] }
5455
| { type: 'message_start'; message: Message }
@@ -79,6 +80,12 @@ export type AgentEvent =
7980

8081
export interface AgentOptions {
8182
initialState: Pick<AgentState, 'model'> & Partial<Omit<AgentState, 'model'>>;
83+
/**
84+
* Maximum number of model invocations the agent will perform per run.
85+
* One model call counts as one step. Counter persists across `continue()`
86+
* — it only resets in `prompt()`. Default: unlimited.
87+
*/
88+
maxSteps?: number;
8289
streamFn?: (
8390
model: ModelDescriptor,
8491
context: Context,

0 commit comments

Comments
 (0)