From 3d6a4858e369a3d3459a144a35677afa32f64963 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 29 May 2026 12:21:30 +0800 Subject: [PATCH 1/2] feat: remove default per-turn step limit of 1000 Previously, runTurn enforced a hard-coded default limit of 1000 steps per turn. This limit was often hit during long-running tasks. Now, when max_steps_per_turn is not configured, the turn has no step limit. Users can still set max_steps_per_turn in [loop_control] to enforce a custom limit if desired. - run-turn.ts: remove DEFAULT_MAX_STEPS, check maxSteps only when defined - docs: update default value description for max_steps_per_turn - tests: add test for omitted maxSteps case --- .changeset/unlimited-max-steps-per-turn.md | 6 +++++ docs/en/configuration/config-files.md | 3 +-- docs/zh/configuration/config-files.md | 3 +-- packages/agent-core/src/loop/run-turn.ts | 6 ++--- .../test/loop/turn-lifecycle.e2e.test.ts | 25 +++++++++++++------ .../examples/kimi-harness-config-smoke.ts | 1 - packages/node-sdk/test/config.test.ts | 2 -- 7 files changed, 28 insertions(+), 18 deletions(-) create mode 100644 .changeset/unlimited-max-steps-per-turn.md 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..336bb89842 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; leave unset for unlimited | | `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..2edc63e22f 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` | — | 单轮最大步数;不设置则无上限 | | `max_retries_per_step` | `integer` | `3` | 单步最大重试次数 | | `reserved_context_size` | `integer` | — | 预留给响应生成的 token 数;上下文逼近该阈值时触发压缩 | diff --git a/packages/agent-core/src/loop/run-turn.ts b/packages/agent-core/src/loop/run-turn.ts index 92e9e6f321..6af964598c 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 && 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..0d9ac5caa4 100644 --- a/packages/agent-core/test/loop/turn-lifecycle.e2e.test.ts +++ b/packages/agent-core/test/loop/turn-lifecycle.e2e.test.ts @@ -187,16 +187,27 @@ describe('runTurn — turn lifecycle', () => { expect(interrupted[0]?.activeStep).toBeUndefined(); }); - 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, From acefff5711412b88177e1b4e1a79bed27d58a1d5 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 29 May 2026 12:25:18 +0800 Subject: [PATCH 2/2] feat(run-turn): allow max_steps=0 as unlimited override - maxSteps check now requires maxSteps > 0, so 0 behaves the same as omitting the value (unlimited). - Relax config schema min from 1 to 0 for maxStepsPerTurn. - Update docs to document the 0-as-unlimited override semantics. - Replace maxSteps=0 boundary test with one asserting unlimited behavior. --- docs/en/configuration/config-files.md | 2 +- docs/zh/configuration/config-files.md | 2 +- packages/agent-core/src/config/schema.ts | 2 +- packages/agent-core/src/loop/run-turn.ts | 2 +- .../test/loop/turn-lifecycle.e2e.test.ts | 31 +++++++++---------- 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 336bb89842..38985f214f 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -153,7 +153,7 @@ max_context_size = 1047576 | Field | Type | Default | Description | | --- | --- | --- | --- | -| `max_steps_per_turn` | `integer` | — | Maximum number of steps per turn; leave unset for unlimited | +| `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 2edc63e22f..ab1c9c6c36 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -153,7 +153,7 @@ max_context_size = 1047576 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `max_steps_per_turn` | `integer` | — | 单轮最大步数;不设置则无上限 | +| `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 6af964598c..2e102cb5a3 100644 --- a/packages/agent-core/src/loop/run-turn.ts +++ b/packages/agent-core/src/loop/run-turn.ts @@ -67,7 +67,7 @@ export async function runTurn(input: RunTurnInput): Promise { while (true) { signal.throwIfAborted(); - if (maxSteps !== undefined && 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 0d9ac5caa4..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,25 +166,24 @@ 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('does not enforce a max step limit when maxSteps is omitted', async () => {