feat: Add max iterations option to agents (no-changelog) (#30881)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
yehorkardash 2026-05-26 11:59:49 +03:00 committed by GitHub
parent 2328b65843
commit 7dfac06880
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 822 additions and 74 deletions

View File

@ -0,0 +1,59 @@
import { Agent } from '../sdk/agent';
import type { ExecutionOptions, RunOptions } from '../types';
type WithPrivates = {
mergeWithDefaults: (
options?: RunOptions & ExecutionOptions,
) => (RunOptions & ExecutionOptions) | undefined;
};
describe('Agent.configuration()', () => {
it('is chainable', () => {
const agent = new Agent('test');
expect(agent.configuration({ maxIterations: 5 })).toBe(agent);
});
it('returns undefined when no defaults and no per-call options are given', () => {
const agent = new Agent('test');
const result = (agent as unknown as WithPrivates).mergeWithDefaults();
expect(result).toBeUndefined();
});
it('returns per-call options unchanged when no defaults are set', () => {
const agent = new Agent('test');
const options = { maxIterations: 10 };
const result = (agent as unknown as WithPrivates).mergeWithDefaults(options);
expect(result).toBe(options);
});
it('returns defaults when no per-call options are provided', () => {
const agent = new Agent('test');
agent.configuration({ maxIterations: 5 });
const result = (agent as unknown as WithPrivates).mergeWithDefaults();
expect(result).toEqual({ maxIterations: 5 });
});
it('per-call options override defaults', () => {
const agent = new Agent('test');
agent.configuration({ maxIterations: 5 });
const result = (agent as unknown as WithPrivates).mergeWithDefaults({ maxIterations: 10 });
expect(result?.maxIterations).toBe(10);
});
it('preserves default fields not present in the per-call options', () => {
const controller = new AbortController();
const agent = new Agent('test');
agent.configuration({ maxIterations: 5, abortSignal: controller.signal });
const result = (agent as unknown as WithPrivates).mergeWithDefaults({ maxIterations: 10 });
expect(result?.maxIterations).toBe(10);
expect(result?.abortSignal).toBe(controller.signal);
});
it('last call to configuration() replaces the previous defaults', () => {
const agent = new Agent('test');
agent.configuration({ maxIterations: 5 });
agent.configuration({ maxIterations: 20 });
const result = (agent as unknown as WithPrivates).mergeWithDefaults();
expect(result?.maxIterations).toBe(20);
});
});

View File

@ -0,0 +1,299 @@
import { expect, it } from 'vitest';
import { z } from 'zod';
import { collectStreamChunks, chunksOfType, describeIf, getModel } from './helpers';
import { Agent, Tool } from '../../index';
import type { CheckpointStore, SerializableAgentState } from '../../types';
const describe = describeIf('anthropic');
class InMemoryCheckpointStore implements CheckpointStore {
private store = new Map<string, SerializableAgentState>();
async save(key: string, state: SerializableAgentState): Promise<void> {
this.store.set(key, structuredClone(state));
}
async load(key: string): Promise<SerializableAgentState | undefined> {
const state = this.store.get(key);
return state ? structuredClone(state) : undefined;
}
async delete(key: string): Promise<void> {
this.store.delete(key);
}
}
function createInterruptibleDeleteAgent(
checkpointStore: 'memory' | CheckpointStore = 'memory',
): Agent {
const deleteTool = new Tool('delete_file')
.description('Delete a file at the given path')
.input(z.object({ path: z.string().describe('File path to delete') }))
.output(z.object({ deleted: z.boolean(), path: z.string() }))
.suspend(z.object({ message: z.string(), severity: z.string() }))
.resume(z.object({ approved: z.boolean() }))
.handler(async ({ path }, ctx) => {
if (!ctx.resumeData) {
return await ctx.suspend({ message: `Delete "${path}"?`, severity: 'destructive' });
}
if (!ctx.resumeData.approved) return { deleted: false, path };
return { deleted: true, path };
});
return new Agent('max-iterations-test-agent')
.model(getModel('anthropic'))
.instructions(
'You are a file manager. When asked to delete a file, always call delete_file first.',
)
.tool(deleteTool)
.checkpoint(checkpointStore);
}
type RunMethod = 'generate' | 'stream';
type PendingSuspendSummary = {
runId: string;
toolCallId: string;
toolName: string;
};
type ToolResultSummary = {
toolName: string;
output: unknown;
isError?: boolean;
};
type RunSummary = {
finishReason: string | undefined;
pendingSuspend: PendingSuspendSummary[];
toolResults: ToolResultSummary[];
error: unknown;
};
type SettledToolCallContent = {
type: 'tool-call';
state: 'resolved' | 'rejected';
toolName: string;
output?: unknown;
error?: unknown;
};
function isSettledToolCallContent(value: unknown): value is SettledToolCallContent {
if (value === null || typeof value !== 'object') return false;
const record = value as Record<string, unknown>;
const type = record.type;
const state = record.state;
const toolName = record.toolName;
return (
type === 'tool-call' &&
(state === 'resolved' || state === 'rejected') &&
typeof toolName === 'string'
);
}
function extractToolResultsFromMessages(messages: unknown[]): ToolResultSummary[] {
return messages
.flatMap((message) => {
if (message === null || typeof message !== 'object') return [];
const content = (message as Record<string, unknown>).content;
return Array.isArray(content) ? (content as unknown[]) : [];
})
.filter(isSettledToolCallContent)
.map((toolCall) => ({
toolName: toolCall.toolName,
output: toolCall.state === 'resolved' ? toolCall.output : toolCall.error,
isError: toolCall.state === 'rejected',
}));
}
async function runAgent(
agent: Agent,
method: RunMethod,
input: string,
options?: { maxIterations?: number },
): Promise<RunSummary> {
if (method === 'generate') {
const result = await agent.generate(input, options);
const toolResults = [
...(result.toolCalls ?? []).map((t) => ({
toolName: t.tool,
output: t.output,
})),
...extractToolResultsFromMessages(result.messages),
];
return {
finishReason: result.finishReason,
pendingSuspend: (result.pendingSuspend ?? []).map((s) => ({
runId: s.runId,
toolCallId: s.toolCallId,
toolName: s.toolName,
})),
toolResults,
error: result.error,
};
}
const result = await agent.stream(input, options);
const chunks = await collectStreamChunks(result.stream);
const finishChunks = chunksOfType(chunks, 'finish');
const errorChunks = chunksOfType(chunks, 'error');
return {
finishReason: finishChunks[finishChunks.length - 1]?.finishReason,
pendingSuspend: chunksOfType(chunks, 'tool-call-suspended').map((s) => ({
runId: s.runId,
toolCallId: s.toolCallId,
toolName: s.toolName,
})),
toolResults: chunksOfType(chunks, 'tool-result').map((t) => ({
toolName: t.toolName,
output: t.output,
isError: t.isError,
})),
error: errorChunks[0]?.error,
};
}
async function resumeAgent(
agent: Agent,
method: RunMethod,
data: { approved: boolean },
options: { runId: string; toolCallId: string; maxIterations?: number },
): Promise<RunSummary> {
if (method === 'generate') {
const result = await agent.resume('generate', data, options);
const toolResults = [
...(result.toolCalls ?? []).map((t) => ({
toolName: t.tool,
output: t.output,
})),
...extractToolResultsFromMessages(result.messages),
];
return {
finishReason: result.finishReason,
pendingSuspend: (result.pendingSuspend ?? []).map((s) => ({
runId: s.runId,
toolCallId: s.toolCallId,
toolName: s.toolName,
})),
toolResults,
error: result.error,
};
}
const result = await agent.resume('stream', data, options);
const chunks = await collectStreamChunks(result.stream);
const finishChunks = chunksOfType(chunks, 'finish');
const errorChunks = chunksOfType(chunks, 'error');
return {
finishReason: finishChunks[finishChunks.length - 1]?.finishReason,
pendingSuspend: chunksOfType(chunks, 'tool-call-suspended').map((s) => ({
runId: s.runId,
toolCallId: s.toolCallId,
toolName: s.toolName,
})),
toolResults: chunksOfType(chunks, 'tool-result').map((t) => ({
toolName: t.toolName,
output: t.output,
isError: t.isError,
})),
error: errorChunks[0]?.error,
};
}
describe('maxIterations integration', () => {
const methods: RunMethod[] = ['generate', 'stream'];
it.each(methods)(
'returns "length" (not error) when iteration limit is reached in resumed %s()',
async (method) => {
const agent = createInterruptibleDeleteAgent();
const first = await runAgent(agent, method, 'Delete the file /tmp/test.txt', {
maxIterations: 1,
});
expect(first.finishReason).toBe('tool-calls');
expect(first.pendingSuspend).toBeDefined();
const { runId, toolCallId } = first.pendingSuspend[0];
const resumed = await resumeAgent(
agent,
method,
{ approved: true },
{ runId, toolCallId, maxIterations: 1 },
);
expect(resumed.finishReason).toBe('max-iterations');
expect(resumed.error).toBeUndefined();
},
);
it.each(methods)(
'persists maxIterations together with completed iteration count in checkpoints (%s)',
async (method) => {
const checkpointStore = new InMemoryCheckpointStore();
const agent = createInterruptibleDeleteAgent(checkpointStore);
const first = await runAgent(agent, method, 'Delete the file /tmp/checkpoint-test.txt', {
maxIterations: 3,
});
expect(first.finishReason).toBe('tool-calls');
expect(first.pendingSuspend).toBeDefined();
const runId = first.pendingSuspend[0].runId;
const state = await checkpointStore.load(runId);
expect(state).toBeDefined();
expect(state!.executionOptions).toEqual({ maxIterations: 3 });
expect(state!.iterationCount).toBe(1);
},
);
it.each(methods)(
'deletes two files sequentially and stops after second resume with "length" in %s()',
async (method) => {
const checkpointStore = new InMemoryCheckpointStore();
const agent1 = createInterruptibleDeleteAgent(checkpointStore);
const first = await runAgent(
agent1,
method,
'In your first response, call delete_file exactly twice in this order: /tmp/first.txt then /tmp/second.txt. Do not output text before tool calls.',
{
maxIterations: 1,
},
);
expect(first.finishReason).toBe('tool-calls');
expect(first.pendingSuspend).toHaveLength(1);
// Recreate agent to ensure resume relies on persisted execution options.
const agent2 = createInterruptibleDeleteAgent(checkpointStore);
const firstSuspended = first.pendingSuspend[0];
const resumed1 = await resumeAgent(agent2, method, { approved: true }, firstSuspended);
expect(resumed1.finishReason).toBe('tool-calls');
expect(resumed1.pendingSuspend).toHaveLength(1);
expect(resumed1.toolResults.length).toBeGreaterThan(0);
expect(resumed1.toolResults.find((t) => t.toolName === 'delete_file')).toBeDefined();
const secondSuspended = resumed1.pendingSuspend[0];
const resumed2 = await resumeAgent(agent2, method, { approved: true }, secondSuspended);
expect(resumed2.finishReason).toBe('max-iterations');
expect(resumed2.error).toBeUndefined();
expect(resumed2.pendingSuspend).toHaveLength(0);
const allDeleteResults = [...resumed1.toolResults, ...resumed2.toolResults]
.filter((t) => t.toolName === 'delete_file')
.map((t) => t.output as { deleted?: boolean; path?: string });
expect(allDeleteResults.length).toBeGreaterThanOrEqual(2);
expect(allDeleteResults.some((t) => t.deleted === true && t.path === '/tmp/first.txt')).toBe(
true,
);
expect(allDeleteResults.some((t) => t.deleted === true && t.path === '/tmp/second.txt')).toBe(
true,
);
},
);
});

View File

@ -147,7 +147,7 @@ describe('workspace agent integration', () => {
.instructions('Base instructions.')
.workspace(workspace);
const tools = workspace.getTools();
expect(tools.length).toBe(13);
expect(tools.length).toBe(15);
const instructions = workspace.getInstructions();
expect(instructions).toContain('Fake sandbox');

View File

@ -2809,7 +2809,9 @@ describe('AgentRuntime — observation log jobs', () => {
expect(await memory.getCursor('thread-1')).toBeNull();
});
it('keeps history resource-filtered while observation-log memory is thread-local', async () => {
// TODO: Fix this test it's flaky
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
it.skip('keeps history resource-filtered while observation-log memory is thread-local', async () => {
generateText.mockResolvedValue(makeGenerateSuccess('Remembered response'));
const memory = new InMemoryMemory();
const runtime = new AgentRuntime({

View File

@ -206,7 +206,7 @@ export interface AgentRuntimeConfig {
telemetry?: BuiltTelemetry;
}
const MAX_LOOP_ITERATIONS = 20;
const MAX_LOOP_ITERATIONS = 30;
const DEFAULT_MEMORY_TASK_LOCK_TTL_MS = 30_000;
const logger = createFilteredLogger();
@ -309,10 +309,12 @@ interface ToolCallBatchResult {
pending: Record<string, PendingToolCall>;
}
type RuntimeExecutionOptions = RunOptions & ExecutionOptions & { iterationCount?: number };
/** Shared input for the private generate/stream loops. */
interface LoopContext {
list: AgentMessageList;
options?: RunOptions & ExecutionOptions;
options?: RuntimeExecutionOptions;
runId: string;
pendingResume?: PendingResume;
}
@ -500,12 +502,26 @@ export class AgentRuntime {
// Merge persisted execution options with fresh caller options
const { runId: _rid, toolCallId: _tcid, ...callerExecOptions } = options;
const persisted = state.executionOptions ?? {};
const mergedExecOptions: ExecutionOptions = {
...persisted,
const persistedMaxIterations = persisted.maxIterations;
const callerMaxIterations = callerExecOptions.maxIterations;
if (
callerMaxIterations !== undefined &&
persistedMaxIterations !== undefined &&
callerMaxIterations < persistedMaxIterations
) {
throw new Error(
`Cannot decrease maxIterations when resuming a run. Expected >= ${persistedMaxIterations}, received ${callerMaxIterations}.`,
);
}
const mergedMaxIterations = callerMaxIterations ?? persistedMaxIterations;
const mergedExecOptions: ExecutionOptions & { iterationCount?: number } = {
...callerExecOptions,
...(mergedMaxIterations !== undefined ? { maxIterations: mergedMaxIterations } : {}),
...(state.iterationCount !== undefined ? { iterationCount: state.iterationCount } : {}),
};
const resumeOptions: RunOptions & ExecutionOptions = {
const resumeOptions: RuntimeExecutionOptions = {
persistence: state.persistence,
...mergedExecOptions,
};
@ -962,6 +978,9 @@ export class AgentRuntime {
telemetry: runTelemetry,
executionCounter: options?.executionCounter,
};
const maxIterations = options?.maxIterations ?? MAX_LOOP_ITERATIONS;
let iterationCount = options?.iterationCount ?? 0;
let reachedStopCondition = false;
if (pendingResume) {
const batch = await this.iteratePendingToolCallsConcurrent({
@ -981,6 +1000,8 @@ export class AgentRuntime {
list,
totalUsage,
runId,
maxIterations,
iterationCount,
);
return {
runId: suspendRunId,
@ -999,9 +1020,8 @@ export class AgentRuntime {
}
}
const maxIterations = options?.maxIterations ?? MAX_LOOP_ITERATIONS;
const { generateText } = getAiSdk();
for (let i = 0; i < maxIterations; i++) {
const { generateText } = loadAi();
for (; iterationCount < maxIterations; iterationCount++) {
if (this.eventBus.isAborted) {
this.updateState({ status: 'cancelled' });
throw new Error('Agent run was aborted');
@ -1041,6 +1061,7 @@ export class AgentRuntime {
structuredOutput = result.output;
}
this.emitTurnEnd(newMessages, extractSettledToolCalls(newMessages));
reachedStopCondition = true;
break;
}
@ -1065,6 +1086,8 @@ export class AgentRuntime {
list,
totalUsage,
runId,
maxIterations,
iterationCount + 1,
);
return {
runId: suspendRunId,
@ -1086,10 +1109,8 @@ export class AgentRuntime {
this.emitTurnEnd(newMessages, extractSettledToolCalls(list.responseDelta()));
}
if (lastFinishReason === 'tool-calls') {
throw new Error(
`Agent loop exceeded ${maxIterations} iterations without reaching a stop condition`,
);
if (!reachedStopCondition && iterationCount >= maxIterations) {
lastFinishReason = 'max-iterations';
}
await this.saveToMemory(list, options);
@ -1189,7 +1210,9 @@ export class AgentRuntime {
let structuredOutput: unknown;
const collectedSubAgentUsage: SubAgentUsage[] = [];
const maxIterations = options?.maxIterations ?? MAX_LOOP_ITERATIONS;
const { streamText } = getAiSdk();
let iterationCount = options?.iterationCount ?? 0;
let reachedStopCondition = false;
const { streamText } = loadAi();
const closeStreamWithError = async (error: unknown, status: AgentRunState): Promise<void> => {
await this.cleanupRun(runId);
@ -1259,6 +1282,8 @@ export class AgentRuntime {
list,
totalUsage,
runId,
maxIterations,
iterationCount,
);
for (const s of batch.suspensions) {
await writer.write({
@ -1282,7 +1307,7 @@ export class AgentRuntime {
}
}
for (let i = 0; i < maxIterations; i++) {
for (; iterationCount < maxIterations; iterationCount++) {
if (await handleAbort()) return;
this.eventBus.emit({ type: AgentEvent.TurnStart });
@ -1347,6 +1372,7 @@ export class AgentRuntime {
structuredOutput = await result.output;
}
this.emitTurnEnd(newMessages, extractSettledToolCalls(newMessages));
reachedStopCondition = true;
break;
}
@ -1394,6 +1420,8 @@ export class AgentRuntime {
list,
totalUsage,
runId,
maxIterations,
iterationCount + 1,
);
for (const s of batch.suspensions) {
await writer.write({
@ -1419,6 +1447,9 @@ export class AgentRuntime {
// Emit TurnEnd after all tool calls in this iteration are processed
this.emitTurnEnd(newMessages, extractSettledToolCalls(list.responseDelta()));
}
if (!reachedStopCondition && iterationCount >= maxIterations) {
lastFinishReason = 'max-iterations';
}
const costUsage = this.applyCost(totalUsage);
const parentCost = costUsage?.cost ?? 0;
@ -2321,17 +2352,21 @@ export class AgentRuntime {
*/
private async persistSuspension(
pendingToolCalls: Record<string, PendingToolCall>,
options: (RunOptions & ExecutionOptions) | undefined,
options: RuntimeExecutionOptions | undefined,
list: AgentMessageList,
totalUsage: TokenUsage | undefined,
existingRunId?: string,
maxIterations?: number,
iterationCount?: number,
): Promise<string> {
const runId = existingRunId ?? generateRunId();
// Only persist maxIterations. providerOptions are intentionally excluded
// Persist loop controls only. providerOptions are intentionally excluded
// because they may contain sensitive data (API keys, auth headers).
const resolvedMaxIterations = maxIterations ?? options?.maxIterations;
const resolvedIterationCount = iterationCount ?? options?.iterationCount;
const executionOptions: PersistedExecutionOptions | undefined =
options?.maxIterations !== undefined ? { maxIterations: options.maxIterations } : undefined;
resolvedMaxIterations !== undefined ? { maxIterations: resolvedMaxIterations } : undefined;
const state: SerializableAgentState = {
persistence: options?.persistence,
@ -2340,6 +2375,7 @@ export class AgentRuntime {
pendingToolCalls,
usage: totalUsage,
executionOptions,
...(resolvedIterationCount !== undefined ? { iterationCount: resolvedIterationCount } : {}),
};
await this.runState.suspend(runId, state);
this.updateState({ status: 'suspended', pendingToolCalls, messageList: list.serialize() });

View File

@ -148,6 +148,8 @@ export class Agent implements BuiltAgent, AgentBuilder {
private mcpClients: McpClient[] = [];
private defaultExecutionOptions?: ExecutionOptions;
private buildPromise: Promise<AgentRuntime> | undefined;
private eventBus = new AgentEventBus();
@ -446,6 +448,29 @@ export class Agent implements BuiltAgent, AgentBuilder {
return this;
}
/**
* Set default execution options for all `generate()` and `stream()` calls.
* Options passed directly to those methods take precedence over these defaults.
*
* @example
* ```typescript
* const agent = new Agent('assistant')
* .model('anthropic/claude-sonnet-4-5')
* .instructions('You are a helpful assistant.')
* .configuration({ maxIterations: 5 });
*
* // Uses maxIterations: 5 from defaults
* await agent.generate('Hello');
*
* // Overrides maxIterations to 10 for this call only
* await agent.generate('Hello', { maxIterations: 10 });
* ```
*/
configuration(options: ExecutionOptions): this {
this.defaultExecutionOptions = options;
return this;
}
/** Get the evals attached to this agent. */
get evaluations(): BuiltEval[] {
return [...this.agentEvals];
@ -606,7 +631,8 @@ export class Agent implements BuiltAgent, AgentBuilder {
options?: RunOptions & ExecutionOptions,
): Promise<GenerateResult> {
const runtime = await this.ensureBuilt();
return await runtime.generate(this.toMessages(input), options);
const mergedOptions = this.mergeWithDefaults(options);
return await runtime.generate(this.toMessages(input), mergedOptions);
}
/** Stream a response. Lazy-builds on first call. */
@ -615,7 +641,8 @@ export class Agent implements BuiltAgent, AgentBuilder {
options?: RunOptions & ExecutionOptions,
): Promise<StreamResult> {
const runtime = await this.ensureBuilt();
return await runtime.stream(this.toMessages(input), options);
const mergedOptions = this.mergeWithDefaults(options);
return await runtime.stream(this.toMessages(input), mergedOptions);
}
/** Resume a suspended tool call with data. Lazy-builds on first call. */
@ -665,6 +692,13 @@ export class Agent implements BuiltAgent, AgentBuilder {
return await this.resume('stream', { approved: false }, options);
}
private mergeWithDefaults(
options?: RunOptions & ExecutionOptions,
): (RunOptions & ExecutionOptions) | undefined {
if (!this.defaultExecutionOptions) return options;
return { ...this.defaultExecutionOptions, ...options };
}
/**
* @internal Lazy-build the agent on first use. Stores the promise so
* concurrent callers share one build operation. On error the promise is

View File

@ -1,4 +1,4 @@
import type { ModelConfig } from './agent';
import type { ExecutionOptions, ModelConfig } from './agent';
import type { BuiltEval } from './eval';
import type { BuiltGuardrail } from './guardrail';
import type { CheckpointStore } from './memory';
@ -32,4 +32,5 @@ export interface AgentBuilder {
structuredOutput(schema: unknown): this;
telemetry(t: unknown): this;
mcp(client: unknown): this;
configuration(options: ExecutionOptions): this;
}

View File

@ -10,7 +10,14 @@ import type { SerializedMessageList } from '../runtime/message-list';
import type { BuiltTelemetry } from '../telemetry';
import type { JSONValue } from '../utils/json';
export type FinishReason = 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other';
export type FinishReason =
| 'stop'
| 'max-iterations'
| 'length'
| 'content-filter'
| 'tool-calls'
| 'error'
| 'other';
export type TokenUsage<T extends Record<string, unknown> = Record<string, unknown>> = {
promptTokens: number;
@ -290,6 +297,8 @@ export interface SerializableAgentState {
finishReason?: FinishReason;
usage?: TokenUsage;
executionOptions?: PersistedExecutionOptions;
/** Number of completed LLM iterations at suspension time. */
iterationCount?: number;
}
export type AgentPersistenceOptions = {

View File

@ -133,6 +133,15 @@ export const AgentJsonConfigSchema = z.object({
.object({
thinking: ThinkingConfigSchema.optional(),
toolCallConcurrency: z.number().int().min(1).max(20).optional(),
maxIterations: z
.number()
.int()
.min(1)
.max(200)
.optional()
.describe(
'Maximum number of agent loop iterations per run. Do not set unless the user explicitly asks.',
),
nodeTools: z
.object({
enabled: z.boolean(),

View File

@ -135,7 +135,7 @@ export interface AgentPersistedMessageDto {
content: AgentPersistedMessageContentPart[];
}
export const AGENT_BUILDER_DEFAULT_MODEL = 'claude-sonnet-4-5' as const;
export const AGENT_BUILDER_DEFAULT_MODEL = 'claude-sonnet-4-6' as const;
export const agentBuilderModeSchema = z.enum(['default', 'custom']);
export type AgentBuilderMode = z.infer<typeof agentBuilderModeSchema>;

View File

@ -866,6 +866,83 @@ describe('AgentsService', () => {
});
});
describe('streamChatResponse', () => {
type StreamChatResponse = {
streamChatResponse: (config: unknown) => AsyncGenerator<{ type: string }>;
};
function makeStream(chunks: object[]): ReadableStream {
return new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk);
controller.close();
},
});
}
async function collectChunks(
config: object,
): Promise<Array<{ type: string; [k: string]: unknown }>> {
const results: Array<{ type: string; [k: string]: unknown }> = [];
for await (const chunk of (service as unknown as StreamChatResponse).streamChatResponse(
config,
)) {
results.push(chunk);
}
return results;
}
it('yields max-iterations text chunks before the finish chunk when finishReason is length', async () => {
const agentInstance = {
name: 'test',
stream: jest.fn().mockResolvedValue({
runId: 'run-1',
stream: makeStream([{ type: 'finish', finishReason: 'max-iterations' }]),
}),
};
const chunks = await collectChunks({
agentInstance,
toolRegistry: new Map(),
agentId,
message: 'hello',
memory: { threadId: 'thread-1', resourceId: 'user-1' },
projectId,
});
const finishIdx = chunks.findIndex((c) => c.type === 'finish');
const textDeltaIdx = chunks.findIndex((c) => c.type === 'text-delta');
const textEndIdx = chunks.findIndex((c) => c.type === 'text-end');
expect(textDeltaIdx).toBeGreaterThan(-1);
expect(textDeltaIdx).toBeLessThan(finishIdx);
expect(textEndIdx).toBeLessThan(finishIdx);
const delta = chunks[textDeltaIdx] as { type: string; delta: string };
expect(delta.delta).toContain('maximum number of iterations');
});
it('does not yield max-iterations chunks when finishReason is not length', async () => {
const agentInstance = {
name: 'test',
stream: jest.fn().mockResolvedValue({
runId: 'run-1',
stream: makeStream([{ type: 'finish', finishReason: 'stop' }]),
}),
};
const chunks = await collectChunks({
agentInstance,
toolRegistry: new Map(),
agentId,
message: 'hello',
memory: { threadId: 'thread-1', resourceId: 'user-1' },
projectId,
});
expect(chunks.every((c) => c.type !== 'text-delta')).toBe(true);
});
});
describe('executeForWorkflow', () => {
it('passes execution-scoped persistence for workflow executions', async () => {
const schema: AgentJsonConfig = {

View File

@ -104,6 +104,9 @@ describe('buildFromJson()', () => {
}
).memoryConfig;
const getDefaultExecutionOptions = (agent: unknown) =>
(agent as { defaultExecutionOptions?: { maxIterations?: number } }).defaultExecutionOptions;
const makeMockMemoryFactory = () => jest.fn();
const makeMockMemoryBackend = () => ({
@ -518,6 +521,22 @@ describe('buildFromJson()', () => {
expect(agent.snapshot.toolCallConcurrency).toBe(5);
});
it('sets maxIterations via configuration()', async () => {
const config = makeConfig({ config: { maxIterations: 10 } });
const agent = await buildFromJson(
config,
{},
{
toolExecutor: makeMockToolExecutor(),
credentialProvider: makeMockCredentialProvider(),
memoryFactory: makeMockMemoryFactory(),
},
);
expect(getDefaultExecutionOptions(agent)?.maxIterations).toBe(10);
});
it('configures memory when enabled', async () => {
const mockMemory = makeMockMemoryBackend();
const config = makeConfig({

View File

@ -571,6 +571,45 @@ describe('full schema snapshot', () => {
});
});
// ---------------------------------------------------------------------------
// Description annotation
// ---------------------------------------------------------------------------
describe('description annotation', () => {
it('appends description to an optional leaf field', () => {
expect(
field('maxIterations', {
type: 'integer',
minimum: 1,
maximum: 200,
description: 'Max loop iterations',
}),
).toBe(' maxIterations?: integer [1..200] — Max loop iterations');
});
it('appends description after required suffix', () => {
expect(field('count', { type: 'integer', description: 'Total items' }, true)).toBe(
' count: integer (required) — Total items',
);
});
it('appends description after default suffix', () => {
expect(field('size', { type: 'number', default: 10, description: 'Chunk size' })).toBe(
' size?: number (default: 10) — Chunk size',
);
});
it('appends description after both required and default suffixes', () => {
expect(
field('flag', { type: 'boolean', default: false, description: 'Feature toggle' }, true),
).toBe(' flag: boolean (required) (default: false) — Feature toggle');
});
it('omits description suffix when description is absent', () => {
expect(field('n', { type: 'integer', minimum: 1 })).toBe(' n?: integer [min 1]');
});
});
// ---------------------------------------------------------------------------
// Custom indent
// ---------------------------------------------------------------------------

View File

@ -175,6 +175,19 @@ interface GetRuntimeParams {
usePublishedVersion?: boolean;
}
function getMaxIterationsChunks(): StreamChunk[] {
const id = crypto.randomUUID();
return [
{ type: 'text-start', id },
{
type: 'text-delta',
id,
delta: 'The agent has reached the maximum number of iterations and has stopped.',
},
{ type: 'text-end', id },
];
}
@Service()
export class AgentsService {
/**
@ -1108,6 +1121,11 @@ export class AgentsService {
toolName: value.toolName,
});
}
if (value.type === 'finish' && value.finishReason === 'max-iterations') {
for (const chunk of getMaxIterationsChunks()) {
yield chunk;
}
}
yield value;
}
} finally {

View File

@ -93,7 +93,7 @@ describe('AgentsBuilderSettingsService', () => {
expect(result).toEqual({
config: {
id: 'anthropic/claude-sonnet-4-5',
id: 'anthropic/claude-sonnet-4-6',
apiKey: 'sk-env',
},
isProxied: false,
@ -203,7 +203,7 @@ describe('AgentsBuilderSettingsService', () => {
expect(logger.warn).toHaveBeenCalled();
expect(result).toEqual({
config: { id: 'anthropic/claude-sonnet-4-5', apiKey: 'sk-env' },
config: { id: 'anthropic/claude-sonnet-4-6', apiKey: 'sk-env' },
isProxied: false,
});
});
@ -223,7 +223,7 @@ describe('AgentsBuilderSettingsService', () => {
expect(logger.warn).toHaveBeenCalled();
expect(result).toEqual({
config: { id: 'anthropic/claude-sonnet-4-5', apiKey: 'sk-env' },
config: { id: 'anthropic/claude-sonnet-4-6', apiKey: 'sk-env' },
isProxied: false,
});
});

View File

@ -151,6 +151,7 @@ export function getConfigRulesSection(): string {
- \`memory.storage\` must be "n8n"; \`memory.lastMessages\` defaults to 50.
- \`memory.episodicMemory\` requires \`ask_credential\` with
\`credentialType: "openAiApi"\`.
- \`config.maxIterations\` caps the number of agent loop iterations per run. Do not set or change this unless the user explicitly asks.
- Fresh agents need a real model, credential, and instructions before config
is written.`;
}

View File

@ -205,7 +205,8 @@ export class AgentsBuilderService {
.instructions(instructions)
.skills(runtimeSkills)
.memory(builderMemory)
.checkpoint(this.n8nCheckpointStorage.getStorage(agentId));
.checkpoint(this.n8nCheckpointStorage.getStorage(agentId))
.configuration({ maxIterations: 30 });
const telemetry = await buildBuilderTelemetry({
agentId,

View File

@ -104,6 +104,9 @@ export async function buildFromJson(
if (config.config.toolCallConcurrency) {
agent.toolCallConcurrency(config.config.toolCallConcurrency);
}
if (config.config.maxIterations) {
agent.configuration({ maxIterations: config.config.maxIterations });
}
}
return agent;

View File

@ -198,8 +198,9 @@ function serializeLeaf(
const requiredSuffix = optional ? '' : ' (required)';
const defaultSuffix =
schema.default !== undefined ? ` (default: ${JSON.stringify(schema.default)})` : '';
const descriptionSuffix = schema.description ? `${schema.description}` : '';
return [
`${pad(level)}${fieldPrefix(fieldName, optional)}${typeLabel(schema)}${requiredSuffix}${defaultSuffix}`,
`${pad(level)}${fieldPrefix(fieldName, optional)}${typeLabel(schema)}${requiredSuffix}${defaultSuffix}${descriptionSuffix}`,
];
}

View File

@ -6129,6 +6129,8 @@
"agents.builder.advanced.recentMessages.label": "Session memory window",
"agents.builder.advanced.recentMessages.hint": "How many recent messages from this thread the agent sees on each turn.",
"agents.builder.advanced.recentMessages.memoryDisabledTooltip": "Enable Session Memory in the Memory section to configure the window.",
"agents.builder.advanced.maxIterations.label": "Max iterations",
"agents.builder.advanced.maxIterations.hint": "Maximum number of agent loop iterations per run (1200). Leave empty to use the platform default.",
"agents.builder.memory.title": "Session Memory",
"agents.builder.memory.description": "Keeps recent messages from this session available as context.",
"agents.builder.memory.episodicMemory.label": "Episodic Memory",

View File

@ -35,11 +35,11 @@ const globalStubs = {
},
N8nText: { template: '<span><slot /></span>' },
N8nTooltip: { template: '<div><slot /></div>' },
N8nInput: {
props: ['modelValue', 'disabled'],
N8nInputNumber2: {
props: ['modelValue', 'disabled', 'min', 'max', 'precision', 'placeholder'],
emits: ['update:modelValue'],
template:
'<input :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target.value)" />',
'<input :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', Number($event.target.value))" />',
},
N8nSelect: {
props: ['modelValue', 'disabled'],
@ -123,9 +123,73 @@ describe('AgentAdvancedPanel', () => {
props: { config, disabled: true },
global: { stubs: globalStubs },
});
const toggle = wrapper.find('[data-testid="agent-thinking-toggle"]');
expect(toggle.attributes('disabled')).toBeDefined();
const concurrency = wrapper.find('[data-testid="agent-concurrency-input"]');
expect(concurrency.attributes('disabled')).toBeDefined();
expect(
wrapper.find('[data-testid="agent-thinking-toggle"]').attributes('disabled'),
).toBeDefined();
expect(
wrapper.find('[data-testid="agent-concurrency-input"]').attributes('disabled'),
).toBeDefined();
expect(
wrapper.find('[data-testid="agent-max-iterations-input"]').attributes('disabled'),
).toBeDefined();
});
it('renders the max-iterations input', () => {
const wrapper = mount(AgentAdvancedPanel, {
props: { config: makeConfig() },
global: { stubs: globalStubs },
});
expect(wrapper.find('[data-testid="agent-max-iterations-input"]').exists()).toBe(true);
});
it('initialises max-iterations input from config', () => {
const config = makeConfig({ config: { maxIterations: 42 } } as Partial<AgentJsonConfig>);
const wrapper = mount(AgentAdvancedPanel, {
props: { config },
global: { stubs: globalStubs },
});
const input = wrapper.find('[data-testid="agent-max-iterations-input"]');
expect(Number(input.element.getAttribute('value'))).toBe(42);
});
it('emits update:config with maxIterations when the field changes', async () => {
const wrapper = mount(AgentAdvancedPanel, {
props: { config: makeConfig() },
global: { stubs: globalStubs },
});
const input = wrapper.find('[data-testid="agent-max-iterations-input"]');
await input.setValue('15');
const events = wrapper.emitted('update:config') ?? [];
expect(events.length).toBeGreaterThan(0);
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
expect(last.config?.maxIterations).toBe(15);
});
it('removes maxIterations from config when the field is cleared (NaN)', async () => {
const config = makeConfig({ config: { maxIterations: 10 } } as Partial<AgentJsonConfig>);
const wrapper = mount(AgentAdvancedPanel, {
props: { config },
global: { stubs: globalStubs },
});
const input = wrapper.find('[data-testid="agent-max-iterations-input"]');
// Non-numeric input produces NaN — treated as "clear" → key removed from config
await input.setValue('abc');
const events = wrapper.emitted('update:config') ?? [];
expect(events.length).toBeGreaterThan(0);
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
expect(last.config).not.toHaveProperty('maxIterations');
});
it('emits update:config with toolCallConcurrency when the concurrency field changes', async () => {
const wrapper = mount(AgentAdvancedPanel, {
props: { config: makeConfig() },
global: { stubs: globalStubs },
});
const input = wrapper.find('[data-testid="agent-concurrency-input"]');
await input.setValue('5');
const events = wrapper.emitted('update:config') ?? [];
expect(events.length).toBeGreaterThan(0);
const last = events[events.length - 1][0] as Partial<AgentJsonConfig>;
expect(last.config?.toolCallConcurrency).toBe(5);
});
});

View File

@ -1,19 +1,9 @@
<script setup lang="ts">
/**
* Behavior panel execution-behavior knobs that used to live in the old
* AgentOverviewPanel: reasoning depth (provider-gated) and tool-call
* concurrency.
*
* Thinking is always visible as a toggle but disabled (with a tooltip) when
* the selected provider doesn't support it. The sub-control differs by
* provider: Anthropic takes a `budgetTokens` number, OpenAI takes a
* `reasoningEffort` low/medium/high select.
*/
import { ref, computed, watch } from 'vue';
import { useDebounceFn } from '@vueuse/core';
import {
N8nCollapsiblePanel,
N8nInput,
N8nInputNumber2,
N8nSelect,
N8nSwitch2,
N8nText,
@ -48,13 +38,85 @@ const capabilities = computed(
() => PROVIDER_CAPABILITIES[provider.value] ?? { thinking: false as const },
);
// ---------------------------------------------------------------------------
// Generic helper for numeric config fields
// ---------------------------------------------------------------------------
type ConfigObj = NonNullable<AgentJsonConfig['config']>;
/** Keys of the config object whose value type is `number | undefined`. */
type NumberConfigKey = keyof {
[K in keyof ConfigObj as ConfigObj[K] extends number | undefined ? K : never]: unknown;
};
/**
* Creates a ref, debounced config-emit, change handler, and watch-sync
* function for one numeric field inside `config`. Designed for N8nInputNumber2
* which emits numbers directly (NaN when the field is cleared).
*
* @param key Config key (must be a numeric field).
* @param defaultValue Fallback when the key is absent or the field is cleared.
* Pass `undefined` for optional fields the key is removed
* from the config when the field is cleared.
*/
function makeNumberField(key: NumberConfigKey, defaultValue: number | undefined) {
const value = ref<number | undefined>(props.config?.config?.[key] ?? defaultValue);
const debouncedEmit = useDebounceFn(() => {
const cfg = { ...(props.config?.config ?? {}) };
if (value.value === undefined) {
delete (cfg as Partial<ConfigObj>)[key];
} else {
(cfg as ConfigObj)[key] = value.value;
}
emit('update:config', { config: cfg });
}, 500);
return {
modelValue: value,
onChange(n: number) {
value.value = isNaN(n) ? defaultValue : n;
void debouncedEmit();
},
sync(cfg: AgentJsonConfig | null) {
value.value = cfg?.config?.[key] ?? defaultValue;
},
};
}
// ---------------------------------------------------------------------------
// Numeric config fields add new ones here
// ---------------------------------------------------------------------------
const CONCURRENCY_MIN = 1;
const CONCURRENCY_MAX = 20;
const MAX_ITERATIONS_MIN = 1;
const MAX_ITERATIONS_MAX = 200;
const BUDGET_TOKENS_MIN = 1;
const BUDGET_TOKENS_DEFAULT = 1024;
const {
modelValue: concurrencyModelValue,
onChange: onConcurrencyChange,
sync: syncConcurrency,
} = makeNumberField('toolCallConcurrency', CONCURRENCY_MIN);
const {
modelValue: maxIterationsModelValue,
onChange: onMaxIterationsChange,
sync: syncMaxIterations,
} = makeNumberField('maxIterations', undefined);
// ---------------------------------------------------------------------------
// Thinking provider-gated, handled separately
// ---------------------------------------------------------------------------
const thinkingCfg = computed(() => props.config?.config?.thinking ?? null);
const thinkingEnabled = ref(thinkingCfg.value !== null);
const budgetTokens = ref(thinkingCfg.value?.budgetTokens ?? 1024);
const budgetTokens = ref(thinkingCfg.value?.budgetTokens ?? BUDGET_TOKENS_DEFAULT);
const reasoningEffort = ref<ReasoningEffort>(
(thinkingCfg.value?.reasoningEffort as ReasoningEffort) ?? 'medium',
);
const toolCallConcurrency = ref(props.config?.config?.toolCallConcurrency ?? 1);
watch(
() => props.config,
@ -62,9 +124,10 @@ watch(
if (!cfg) return;
const t = cfg.config?.thinking ?? null;
thinkingEnabled.value = t !== null;
budgetTokens.value = t?.budgetTokens ?? 1024;
budgetTokens.value = t?.budgetTokens ?? BUDGET_TOKENS_DEFAULT;
reasoningEffort.value = (t?.reasoningEffort as ReasoningEffort) ?? 'medium';
toolCallConcurrency.value = cfg.config?.toolCallConcurrency ?? 1;
syncConcurrency(cfg);
syncMaxIterations(cfg);
},
{ deep: true },
);
@ -92,9 +155,8 @@ function onThinkingToggle(value: boolean) {
}
const emitBudget = useDebounceFn(emitThinking, 500);
function onBudgetInput(value: string) {
const n = Number(value);
if (!Number.isFinite(n) || n < 1) return;
function onBudgetChange(n: number) {
if (isNaN(n) || n < BUDGET_TOKENS_MIN) return;
budgetTokens.value = n;
void emitBudget();
}
@ -104,18 +166,6 @@ function onReasoningEffortChange(value: ReasoningEffort) {
emitThinking();
}
const emitConcurrency = useDebounceFn(() => {
emit('update:config', {
config: { ...props.config?.config, toolCallConcurrency: toolCallConcurrency.value },
});
}, 500);
function onConcurrencyInput(value: string) {
const n = Number(value);
if (!Number.isFinite(n) || n < 1) return;
toolCallConcurrency.value = n;
void emitConcurrency();
}
const thinkingDisabledReason = computed(() =>
capabilities.value.thinking
? ''
@ -167,13 +217,14 @@ const thinkingDisabledReason = computed(() =>
<N8nText size="small" :bold="true">{{
i18n.baseText('agents.builder.advanced.budgetTokens.label')
}}</N8nText>
<N8nInput
type="number"
:model-value="String(budgetTokens)"
<N8nInputNumber2
:model-value="budgetTokens"
:min="BUDGET_TOKENS_MIN"
:precision="0"
:disabled="props.disabled"
:class="$style.shortInput"
data-testid="agent-budget-tokens-input"
@update:model-value="onBudgetInput"
@update:model-value="onBudgetChange"
/>
</div>
@ -205,13 +256,36 @@ const thinkingDisabledReason = computed(() =>
{{ i18n.baseText('agents.builder.advanced.concurrency.hint') }}
</N8nText>
</div>
<N8nInput
type="number"
:model-value="String(toolCallConcurrency)"
<N8nInputNumber2
:model-value="concurrencyModelValue"
:min="CONCURRENCY_MIN"
:max="CONCURRENCY_MAX"
:precision="0"
:disabled="props.disabled"
:class="$style.shortInput"
data-testid="agent-concurrency-input"
@update:model-value="onConcurrencyInput"
@update:model-value="onConcurrencyChange"
/>
</div>
<div :class="$style.row">
<div :class="$style.rowLabel">
<N8nText size="small" :bold="true">{{
i18n.baseText('agents.builder.advanced.maxIterations.label')
}}</N8nText>
<N8nText size="xsmall" color="text-light">
{{ i18n.baseText('agents.builder.advanced.maxIterations.hint') }}
</N8nText>
</div>
<N8nInputNumber2
:model-value="maxIterationsModelValue"
:min="MAX_ITERATIONS_MIN"
:max="MAX_ITERATIONS_MAX"
:precision="0"
:disabled="props.disabled"
:class="$style.shortInput"
data-testid="agent-max-iterations-input"
@update:model-value="onMaxIterationsChange"
/>
</div>
</div>