diff --git a/.changeset/unlimited-max-steps-per-turn.md b/.changeset/unlimited-max-steps-per-turn.md new file mode 100644 index 0000000000..e468847e0f --- /dev/null +++ b/.changeset/unlimited-max-steps-per-turn.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code": minor +--- + +Remove the default per-turn step limit of 1000. Users can still set `max_steps_per_turn` in config to enforce a custom limit. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 56a2ddbbc0..38985f214f 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -62,7 +62,6 @@ max_context_size = 262144 mode = "auto" [loop_control] -max_steps_per_turn = 1000 max_retries_per_step = 3 reserved_context_size = 50000 @@ -154,7 +153,7 @@ max_context_size = 1047576 | Field | Type | Default | Description | | --- | --- | --- | --- | -| `max_steps_per_turn` | `integer` | `1000` | Maximum number of steps per turn | +| `max_steps_per_turn` | `integer` | — | Maximum number of steps per turn; set to `0` or leave unset for unlimited. Setting `0` is useful for overriding a previously configured limit. | | `max_retries_per_step` | `integer` | `3` | Maximum retries per step | | `reserved_context_size` | `integer` | — | Number of tokens reserved for response generation; compaction is triggered when the context approaches this threshold | diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 45b5a50854..ab1c9c6c36 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -62,7 +62,6 @@ max_context_size = 262144 mode = "auto" [loop_control] -max_steps_per_turn = 1000 max_retries_per_step = 3 reserved_context_size = 50000 @@ -154,7 +153,7 @@ max_context_size = 1047576 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `max_steps_per_turn` | `integer` | `1000` | 单轮最大步数 | +| `max_steps_per_turn` | `integer` | — | 单轮最大步数;不设置或设为 `0` 则无上限。设为 `0` 可用于显式覆盖此前已配置的限制。 | | `max_retries_per_step` | `integer` | `3` | 单步最大重试次数 | | `reserved_context_size` | `integer` | — | 预留给响应生成的 token 数;上下文逼近该阈值时触发压缩 | diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 3a6567a171..ac3d17e124 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -80,7 +80,7 @@ export const PermissionConfigSchema = z.object({ export type PermissionConfig = z.infer; export const LoopControlSchema = z.object({ - maxStepsPerTurn: z.number().int().min(1).optional(), + maxStepsPerTurn: z.number().int().min(0).optional(), maxRetriesPerStep: z.number().int().min(0).optional(), maxRalphIterations: z.number().int().min(-1).optional(), reservedContextSize: z.number().int().min(0).optional(), diff --git a/packages/agent-core/src/loop/run-turn.ts b/packages/agent-core/src/loop/run-turn.ts index 92e9e6f321..2e102cb5a3 100644 --- a/packages/agent-core/src/loop/run-turn.ts +++ b/packages/agent-core/src/loop/run-turn.ts @@ -28,8 +28,6 @@ import type { TurnResult, } from './types'; -const DEFAULT_MAX_STEPS = 1000; - export interface RunTurnInput { readonly turnId: string; readonly signal: AbortSignal; @@ -53,7 +51,7 @@ export async function runTurn(input: RunTurnInput): Promise { tools, hooks, log, - maxSteps = DEFAULT_MAX_STEPS, + maxSteps, maxRetryAttempts, } = input; let usage: TokenUsage = emptyUsage(); @@ -69,7 +67,7 @@ export async function runTurn(input: RunTurnInput): Promise { while (true) { signal.throwIfAborted(); - if (steps >= maxSteps) { + if (maxSteps !== undefined && maxSteps > 0 && steps >= maxSteps) { throw createMaxStepsExceededError(maxSteps); } diff --git a/packages/agent-core/test/loop/turn-lifecycle.e2e.test.ts b/packages/agent-core/test/loop/turn-lifecycle.e2e.test.ts index 48e275dfad..aa20b150c4 100644 --- a/packages/agent-core/test/loop/turn-lifecycle.e2e.test.ts +++ b/packages/agent-core/test/loop/turn-lifecycle.e2e.test.ts @@ -166,37 +166,47 @@ describe('runTurn — turn lifecycle', () => { expect(interruptedTypes).toContain('max_steps'); }); - it('throws immediately when maxSteps=0 (boundary)', async () => { - const { error, sink, llm, context } = await runTurnExpectingThrow({ + it('does not enforce a max step limit when maxSteps is 0', async () => { + const echo = new EchoTool(); + const { result } = await runTurn({ maxSteps: 0, - responses: [makeEndTurnResponse('never reached')], + tools: [echo], + responses: [ + makeToolUseResponse([makeToolCall('echo', { text: '1' }, 'a')]), + makeToolUseResponse([makeToolCall('echo', { text: '2' }, 'b')]), + makeEndTurnResponse('done'), + ], }); - expect(error).toBeInstanceOf(KimiError); - expect((error as KimiError).code).toBe(ErrorCodes.LOOP_MAX_STEPS_EXCEEDED); - expect(llm.callCount).toBe(0); - // No step envelope was opened - expect(context.stepBegins().length).toBe(0); - expect(context.stepEnds().length).toBe(0); - expect(sink.count('step.begin')).toBe(0); - // turn.interrupted is still emitted without pretending a step started. - const interrupted = sink.byType('turn.interrupted'); - expect(interrupted.length).toBe(1); - expect(interrupted[0]?.reason).toBe('max_steps'); - expect(interrupted[0]?.attemptedSteps).toBe(0); - expect(interrupted[0]?.activeStep).toBeUndefined(); + expect(result.stopReason).toBe('end_turn'); + expect(result.steps).toBe(3); + expect(echo.calls).toEqual([ + { id: 'a', turnId: 'turn-1', args: { text: '1' } }, + { id: 'b', turnId: 'turn-1', args: { text: '2' } }, + ]); }); - it('uses a generous default maxSteps when omitted', async () => { - // We don't want to actually exhaust the default; just prove that a - // single end_turn step under no explicit limit succeeds. The exact - // numeric default is an implementation detail, but it must allow at - // least one step. + it('does not enforce a max step limit when maxSteps is omitted', async () => { + const echo = new EchoTool(); const { result } = await runTurn({ - responses: [makeEndTurnResponse('ok')], + tools: [echo], + responses: [ + makeToolUseResponse([makeToolCall('echo', { text: '1' }, 'a')]), + makeToolUseResponse([makeToolCall('echo', { text: '2' }, 'b')]), + makeToolUseResponse([makeToolCall('echo', { text: '3' }, 'c')]), + makeToolUseResponse([makeToolCall('echo', { text: '4' }, 'd')]), + makeEndTurnResponse('done'), + ], }); + expect(result.stopReason).toBe('end_turn'); - expect(result.steps).toBe(1); + expect(result.steps).toBe(5); + expect(echo.calls).toEqual([ + { id: 'a', turnId: 'turn-1', args: { text: '1' } }, + { id: 'b', turnId: 'turn-1', args: { text: '2' } }, + { id: expect.any(String), turnId: 'turn-1', args: { text: '3' } }, + { id: expect.any(String), turnId: 'turn-1', args: { text: '4' } }, + ]); }); it('aggregates usage across steps including cache fields', async () => { diff --git a/packages/node-sdk/examples/kimi-harness-config-smoke.ts b/packages/node-sdk/examples/kimi-harness-config-smoke.ts index 4a0fc27f94..7c4f7d4d8a 100644 --- a/packages/node-sdk/examples/kimi-harness-config-smoke.ts +++ b/packages/node-sdk/examples/kimi-harness-config-smoke.ts @@ -38,7 +38,6 @@ async function main(): Promise { }, }, loopControl: { - maxStepsPerTurn: 1000, maxRetriesPerStep: 3, maxRalphIterations: 0, reservedContextSize: 50000, diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index bc4956fc5d..3518e848d5 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -56,7 +56,6 @@ capabilities = ["image_in", "thinking", "video_in"] display_name = "Kimi for Coding" [loop_control] -max_steps_per_turn = 1000 max_retries_per_step = 3 max_ralph_iterations = 0 reserved_context_size = 50000 @@ -111,7 +110,6 @@ describe('SDK config TOML', () => { }); expect(config.loopControl).toEqual({ - maxStepsPerTurn: 1000, maxRetriesPerStep: 3, maxRalphIterations: 0, reservedContextSize: 50000,