From 9e19a01489df751088915b1dacc9b0a929b1ca38 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 2 Sep 2026 14:51:52 +0800 Subject: [PATCH 1/4] feat(agent-core-v2): add a handoff step after forced stops and report subagent stop reasons - When the tool-call repeat breaker stops a turn, the loop now runs one text-only handoff step; tool calls issued in that step are refused and the turn ends with the same stop reason. - Tool results can carry stopTurnReason; LoopRunResult and turn.ended expose the forced stop reason. - Subagent results report stop_reason, a resume hint, and a next-step line; a turn that ends without a final message fails with agent.no_final_message, and the step-cap failure is rephrased for the parent model. - Remove the subagent summary length policy and its continuation prompt. --- .changeset/drop-subagent-summary-bounce.md | 5 + .../agent-core-v2/docs/state-manifest.d.ts | 6 +- .../src/agent/loop/handoffStep.ts | 24 +++ packages/agent-core-v2/src/agent/loop/loop.ts | 1 + .../src/agent/loop/loopService.ts | 51 +++-- .../agent-core-v2/src/agent/loop/turnOps.ts | 1 + .../src/agent/toolDedupe/toolDedupe.ts | 2 + .../src/agent/toolDedupe/toolDedupeService.ts | 88 ++++++++- .../agent/toolExecutor/toolExecutorService.ts | 2 + .../src/agent/tools/agent/agentTool.ts | 117 ++++++++++-- .../src/agent/tools/agent/subagent-task.ts | 24 ++- .../agentProfileCatalog.ts | 7 - .../agent-core-v2/src/app/telemetry/events.ts | 13 ++ .../src/features/tower/workerProfile.ts | 12 +- packages/agent-core-v2/src/index.ts | 1 + .../src/session/agentLifecycle/errors.ts | 1 + .../agentLifecycle/profile/profiles.ts | 13 +- .../profile/summary-continuation.md | 5 - .../src/session/subagent/mirrorAgentRun.ts | 4 +- .../src/session/subagent/runAgentTurn.ts | 107 +++++------ .../src/session/subagent/subagent.ts | 10 +- .../src/session/subagent/subagentService.ts | 13 +- .../agent-core-v2/src/tool/toolContract.ts | 2 + .../test/agent/loop/loop.test.ts | 40 ++++ .../test/agent/toolDedupe/toolDedupe.test.ts | 134 ++++++++++++- .../test/features/tower/workerProfile.test.ts | 4 +- .../test/session/subagent/forkParity.test.ts | 5 +- .../session/subagent/runAgentTurn.test.ts | 131 +++++++++++++ packages/agent-core-v2/test/tool/tool.test.ts | 179 +++++++++++++++++- 29 files changed, 843 insertions(+), 159 deletions(-) create mode 100644 .changeset/drop-subagent-summary-bounce.md create mode 100644 packages/agent-core-v2/src/agent/loop/handoffStep.ts delete mode 100644 packages/agent-core-v2/src/session/agentLifecycle/profile/summary-continuation.md create mode 100644 packages/agent-core-v2/test/session/subagent/runAgentTurn.test.ts diff --git a/.changeset/drop-subagent-summary-bounce.md b/.changeset/drop-subagent-summary-bounce.md new file mode 100644 index 00000000000..bc9c2f59cf5 --- /dev/null +++ b/.changeset/drop-subagent-summary-bounce.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Subagent final messages are no longer bounced back for expansion when they are under 200 characters. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index e4ad9f01b93..16c8cac6e7c 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -27,7 +27,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 82 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 83 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -116,6 +116,7 @@ // toolDedupe.callKeyByCallId src/agent/toolDedupe/toolDedupeService.ts // toolDedupe.consecutiveCount src/agent/toolDedupe/toolDedupeService.ts // toolDedupe.consecutiveKey src/agent/toolDedupe/toolDedupeService.ts +// toolDedupe.handoffPhase src/agent/toolDedupe/toolDedupeService.ts // toolDedupe.originalCallIndex src/agent/toolDedupe/toolDedupeService.ts // toolDedupe.stepCalls src/agent/toolDedupe/toolDedupeService.ts // toolDedupe.syntheticCallIds src/agent/toolDedupe/toolDedupeService.ts @@ -1380,6 +1381,7 @@ export interface AgentStateSnapshot { readonly parentToolCallId?: string; readonly model?: string; readonly thinkingEffort?: string; + readonly stopCode?: string; readonly taskId: string; readonly description: string; readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; @@ -1431,6 +1433,7 @@ export interface AgentStateSnapshot { readonly parentToolCallId?: string; readonly model?: string; readonly thinkingEffort?: string; + readonly stopCode?: string; readonly taskId: string; readonly description: string; readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; @@ -1467,6 +1470,7 @@ export interface AgentStateSnapshot { 'toolDedupe.callKeyByCallId': Map; 'toolDedupe.consecutiveCount': number; 'toolDedupe.consecutiveKey': string | null; + 'toolDedupe.handoffPhase': /* HandoffPhase — packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts */ 'idle' | 'active' | 'pending' | 'done'; 'toolDedupe.originalCallIndex': Map; 'toolDedupe.stepCalls': string[]; 'toolDedupe.syntheticCallIds': Set; diff --git a/packages/agent-core-v2/src/agent/loop/handoffStep.ts b/packages/agent-core-v2/src/agent/loop/handoffStep.ts new file mode 100644 index 00000000000..eb27197dc21 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/handoffStep.ts @@ -0,0 +1,24 @@ +import { ContinuationStepRequest } from './stepRequest'; + +export const HANDOFF_STEP_KIND = 'handoff'; + +export interface HandoffStepObserver { + onMaterialize(): void; + onAbort(): void; +} + +export class HandoffStepRequest extends ContinuationStepRequest { + constructor(private readonly observer: HandoffStepObserver) { + super({ kind: HANDOFF_STEP_KIND }); + } + + override onWillMaterialize(): void { + this.observer.onMaterialize(); + } + + override abort(): boolean { + const aborted = super.abort(); + if (aborted) this.observer.onAbort(); + return aborted; + } +} diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index 3fcc68993c9..733686675d0 100644 --- a/packages/agent-core-v2/src/agent/loop/loop.ts +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -75,6 +75,7 @@ export type LoopRunResult = readonly type: 'completed'; readonly steps: number; readonly truncated: boolean; + readonly stopReason?: string; } | { readonly type: 'failed'; diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 3c7ad890753..27c408ae0bd 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -537,6 +537,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { error, durationMs, interruptReason, + stopReason: result.type === 'completed' ? result.stopReason : undefined, }), ); if (error !== undefined) { @@ -675,21 +676,29 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { queue: job?.queue ?? this.standaloneStepQueue, steps: 0, lastStopReason: undefined, + forcedStopReason: undefined, current: undefined, }; } + private completedResult(runtime: LoopRuntime): LoopRunResult { + const truncated = runtime.lastStopReason === 'truncated'; + if (runtime.forcedStopReason === undefined) { + return { type: 'completed', steps: runtime.steps, truncated }; + } + return { + type: 'completed', + steps: runtime.steps, + truncated, + stopReason: runtime.forcedStopReason, + }; + } + private beginLoopStep(runtime: LoopRuntime): BeginStepResult { runtime.current = undefined; runtime.turnSignal.throwIfAborted(); if (!runtime.queue.hasPendingRequests()) { - return { - result: { - type: 'completed', - steps: runtime.steps, - truncated: runtime.lastStopReason === 'truncated', - }, - }; + return { result: this.completedResult(runtime) }; } const maxSteps = this.config.get(LOOP_CONTROL_SECTION)?.maxStepsPerTurn; if (maxSteps !== undefined && maxSteps > 0 && runtime.steps >= maxSteps) { @@ -727,6 +736,9 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } runtime.current = undefined; runtime.lastStopReason = result.stopReason; + if (result.stopTurnReason !== undefined && runtime.forcedStopReason === undefined) { + runtime.forcedStopReason = result.stopTurnReason; + } if (result.stopReason === 'filtered') { throw new Error2(ErrorCodes.PROVIDER_FILTERED, 'Provider safety policy blocked the response.', { name: 'ProviderFilteredError', @@ -734,7 +746,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { }); } if (!result.hookStopTurn) return undefined; - return { type: 'completed', steps: runtime.steps, truncated: result.stopReason === 'truncated' }; + return this.completedResult(runtime); } private async handleLoopStepError( @@ -861,7 +873,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } this.lastRequestTraceId = request.trace.traceId; this.appendResponseContent(turnId, currentStep, stepUuid, response); - const finishReason = await this.executeStepTools( + const { finishReason, stopTurnReason } = await this.executeStepTools( turnId, signal, currentStep, @@ -879,7 +891,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { response.usage, finishReason, ); - return { stopReason: finishReason, hookStopTurn }; + return { stopReason: finishReason, hookStopTurn, stopTurnReason }; } catch (error) { if (!stepEndAppended) { this.context.appendLoopEvent({ @@ -968,13 +980,14 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { stepUuid: string, response: AgentLLMRequestFinish, trace: LLMRequestTrace, - ): Promise { + ): Promise { let finishReason = response.providerFinishReason ?? 'completed'; if (response.message.toolCalls.length === 0) { - return finishReason === 'tool_calls' ? 'other' : finishReason; + return { finishReason: finishReason === 'tool_calls' ? 'other' : finishReason }; } const toolCallUuids = new Map(); let stopTurn = false; + let stopTurnReason: string | undefined; for await (const toolResult of this.toolExecutor.execute(response.message.toolCalls, { signal, turnId, @@ -1003,10 +1016,13 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { toolCallId: toolResult.toolCallId, result: { output: result.output, isError: result.isError, note: result.note }, }); - if (result.stopTurn === true) stopTurn = true; + if (result.stopTurn === true) { + stopTurn = true; + stopTurnReason ??= result.stopTurnReason; + } } finishReason = stopTurn ? 'completed' : 'tool_calls'; - return finishReason; + return { finishReason, stopTurnReason }; } private finishStep( @@ -1239,6 +1255,7 @@ interface LoopRuntime { readonly queue: StepRequestQueue; steps: number; lastStopReason: FinishReason | undefined; + forcedStopReason: string | undefined; current: StepRuntime | undefined; } @@ -1277,6 +1294,12 @@ function interruptReasonFor( type StepExecutionResult = { readonly stopReason: FinishReason; readonly hookStopTurn: boolean; + readonly stopTurnReason?: string; +}; + +type StepToolsOutcome = { + readonly finishReason: FinishReason; + readonly stopTurnReason?: string; }; type LoopErrorDisposition = diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts index 95029d642a7..e4f021c3bc9 100644 --- a/packages/agent-core-v2/src/agent/loop/turnOps.ts +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -100,6 +100,7 @@ export interface TurnEndedPayload { readonly error?: KimiErrorPayload; readonly durationMs?: number; readonly interruptReason?: TurnInterruptReason; + readonly stopReason?: string; } export class TurnEnded extends AgentEvent2 { diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts index 5763f4e21d1..865548a8139 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts @@ -15,6 +15,8 @@ export interface ToolDedupeErrorResult extends ExecutableToolErrorResult { export type ToolDedupeResult = ToolDedupeSuccessResult | ToolDedupeErrorResult; +export const REPEAT_BREAKER_STOP_REASON = 'repeat_breaker'; + export interface IAgentToolDedupeService { readonly _serviceBrand: undefined; } diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index 85ce0885885..5605ca6761f 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -8,19 +8,25 @@ import { canonicalTelemetryArgs } from '#/_base/utils/canonical-args'; import type { ToolCallDedupDetectedEvent, ToolCallRepeatEvent, + ToolCallRepeatHandoffEvent, ToolCallTurnRepeatEvent, } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; import { parseToolCallArguments } from '#/tool/tool-args-parse'; import { IAgentLoopService } from '#/agent/loop/loop'; +import { HandoffStepRequest } from '#/agent/loop/handoffStep'; import { IAgentStateService } from '#/agent/state/agentState'; import { IEventBus } from '#/app/event/eventBus'; import { TurnEnded } from '#/agent/loop/turnOps'; import { wrapSystemReminder } from '#/features/reminder/systemReminder'; import { IAgentToolExecutorService, type ToolCallDupType } from '#/agent/toolExecutor/toolExecutor'; import type { ContentPart } from '#/kosong/contract/message'; -import { IAgentToolDedupeService, type ToolDedupeResult } from './toolDedupe'; +import { + IAgentToolDedupeService, + REPEAT_BREAKER_STOP_REASON, + type ToolDedupeResult, +} from './toolDedupe'; const REMINDER_TEXT_1 = '\n\n' + @@ -56,6 +62,21 @@ const REPEAT_REMINDER_2_START = 5; const REPEAT_REMINDER_3_START = 8; const REPEAT_FORCE_STOP_STREAK = 12; +const HANDOFF_VETO_TEXT = + 'This turn was ended by the repeat breaker after the same tool call was issued ' + + `${String(REPEAT_FORCE_STOP_STREAK)} times in a row. This step accepts a text response only, ` + + 'so the tool call was not executed. Reply in text: the current blocker, what you tried, ' + + 'and what you need next.'; + +const HANDOFF_VETO_RESULT: ToolDedupeResult = { + output: HANDOFF_VETO_TEXT, + isError: true, + stopTurn: true, + stopTurnReason: REPEAT_BREAKER_STOP_REASON, +}; + +type HandoffPhase = 'idle' | 'pending' | 'active' | 'done'; + interface Deferred { readonly promise: Promise; resolve(value: T): void; @@ -116,7 +137,7 @@ function appendReminder(result: ToolDedupeResult, reminderText: string): ToolDed function forceStopResult(result: ToolDedupeResult, reminderText: string): ToolDedupeResult { const withReminder = appendReminder(result, reminderText); - return { ...withReminder, stopTurn: true }; + return { ...withReminder, stopTurn: true, stopTurnReason: REPEAT_BREAKER_STOP_REASON }; } const DEDUPE_PLACEHOLDER_RESULT: ToolDedupeResult = { output: '' }; @@ -155,14 +176,20 @@ export const toolDedupeTurnRepeatCountKey = defineState( 'toolDedupe.turnRepeatCount', () => 0, ); +export const toolDedupeHandoffPhaseKey = defineState( + 'toolDedupe.handoffPhase', + () => 'idle' as HandoffPhase, +); export class AgentToolDedupeService extends Service implements IAgentToolDedupeService { declare readonly _serviceBrand: undefined; private readonly stepDeferreds = new Map>(); + private readonly handoffVetoedCallIds = new Set(); + private forceStoppedInStep = false; constructor( @ITelemetryService private readonly telemetry: ITelemetryService, - @IAgentLoopService loop: IAgentLoopService, + @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService, @IAgentStateService private readonly states: IAgentStateService, @IEventBus eventBus: IEventBus, @@ -178,16 +205,23 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS this.states.contributeState(toolDedupeActiveStepKey); this.states.contributeState(toolDedupeTurnCallRecordsKey); this.states.contributeState(toolDedupeTurnRepeatCountKey); + this.states.contributeState(toolDedupeHandoffPhaseKey); this._register(eventBus.subscribe(TurnEnded, () => this.clearTurnRecords())); loop.hooks.onWillBeginStep.register('toolDedupe', async (ctx, next) => { this.beginStep(ctx.turnId, ctx.step); await next(); }); - loop.hooks.onDidFinishStep.register('toolDedupe', async (_ctx, next) => { + loop.hooks.onDidFinishStep.register('toolDedupe', async (ctx, next) => { this.endStep(); + this.settleHandoff(ctx.turnId); await next(); }); toolExecutor.onBeforeExecuteTool((event) => { + if (this.handoffPhase === 'active') { + this.handoffVetoedCallIds.add(event.toolCall.id); + event.veto(HANDOFF_VETO_RESULT); + return; + } const checked = this.checkToolCall( event.toolCall.id, event.toolCall.name, @@ -199,6 +233,13 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS } }); toolExecutor.hooks.onDidExecuteTool.register('toolDedupe', async (ctx, next) => { + if (this.handoffPhase === 'active') { + this.handoffVetoedCallIds.add(ctx.toolCall.id); + ctx.result = HANDOFF_VETO_RESULT; + ctx.stopTurn = true; + await next(); + return; + } this.registerSkipped( ctx.toolCall.id, ctx.toolCall.name, @@ -284,6 +325,14 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS this.states.set(toolDedupeTurnRepeatCountKey, value); } + private get handoffPhase(): HandoffPhase { + return this.states.get(toolDedupeHandoffPhaseKey); + } + + private set handoffPhase(value: HandoffPhase) { + this.states.set(toolDedupeHandoffPhaseKey, value); + } + private clearTurnRecords(): void { this.turnCallRecords.clear(); this.turnRepeatCount = 0; @@ -294,11 +343,14 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS this.activeTurnId = turnId; this.consecutiveKey = null; this.consecutiveCount = 0; + this.handoffPhase = 'idle'; this.clearTurnRecords(); } if (step !== undefined) { this.activeStep = step; } + this.forceStoppedInStep = false; + this.handoffVetoedCallIds.clear(); for (const deferred of this.stepDeferreds.values()) { deferred.resolve({ @@ -324,6 +376,31 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS } } + private settleHandoff(turnId: number): void { + const phase = this.handoffPhase; + if (phase === 'active') { + this.handoffPhase = 'done'; + const properties: ToolCallRepeatHandoffEvent = { + turn_id: turnId, + outcome: this.handoffVetoedCallIds.size > 0 ? 'vetoed' : 'text', + }; + this.telemetry.track2('tool_call_repeat_handoff', properties); + return; + } + if (phase !== 'idle' || !this.forceStoppedInStep) return; + this.handoffPhase = 'pending'; + this.loop.enqueue( + new HandoffStepRequest({ + onMaterialize: () => { + this.handoffPhase = 'active'; + }, + onAbort: () => { + this.handoffPhase = 'done'; + }, + }), + ); + } + private recordTurnRepeat( toolCallId: string, toolName: string, @@ -455,6 +532,7 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS if (streak >= REPEAT_FORCE_STOP_STREAK) { finalResult = forceStopResult(result, REMINDER_TEXT_3); action = 'stop'; + this.forceStoppedInStep = true; } else if (streak >= REPEAT_REMINDER_3_START) { finalResult = appendReminder(result, REMINDER_TEXT_3); action = 'r3'; @@ -490,6 +568,8 @@ export const __testing = { REPEAT_REMINDER_2_START, REPEAT_REMINDER_3_START, REPEAT_FORCE_STOP_STREAK, + REPEAT_BREAKER_STOP_REASON, + HANDOFF_VETO_TEXT, }; registerScopedService( diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index 47fc56a48cf..9f144d187f6 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -883,6 +883,7 @@ function normalizeToolResult(result: ExecutableToolResult): ToolResult { const base: { output: ToolResult['output']; stopTurn?: boolean; + stopTurnReason?: string; truncated?: true; note?: string; spill?: ToolResultSpill; @@ -893,6 +894,7 @@ function normalizeToolResult(result: ExecutableToolResult): ToolResult { spill: result.spill, spillExempt: result.spillExempt, }; + if (result.stopTurnReason !== undefined) base.stopTurnReason = result.stopTurnReason; if (result.truncated === true) base.truncated = true; if (typeof result.note === 'string' && result.note.length > 0) base.note = result.note; if (result.isError === true) { diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index 1eedfdf1505..1a0008831c8 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -6,6 +6,8 @@ import { userCancellationReason, } from '#/_base/utils/abort'; import { Error2, ErrorCodes, isError2 } from '#/errors'; +import { REPEAT_BREAKER_STOP_REASON } from '#/agent/toolDedupe/toolDedupe'; +import type { AgentTaskInfo } from '#/agent/task/types'; import { toInputJsonSchema } from '#/tool/input-schema'; import { matchesGlobRuleSubject } from '#/tool/rule-match'; import { @@ -336,7 +338,11 @@ export class SubagentTool implements ISubagentTool { thinkingEffort: this.agentLifecycle.handleOf(agentId) ?.accessor.get(IAgentProfileService) .getEffectiveThinkingLevel(), - completion: mirrored.then((r) => ({ result: r.summary, usage: r.usage })), + completion: mirrored.then((r) => ({ + result: r.summary, + usage: r.usage, + stopReason: r.stopReason, + })), }; } @@ -494,9 +500,10 @@ export class SubagentTool implements ISubagentTool { timeoutMs: number, ): Promise { const info = this.tasks.getTask(taskId); + const stopCode = info?.kind === 'agent' ? info.stopCode : undefined; if (info?.status === 'completed') { return { - output: formatForegroundAgentSuccess(handle, await this.tasks.readOutput(taskId)), + output: formatForegroundAgentSuccess(handle, await this.tasks.readOutput(taskId), stopCode), }; } const timedOut = info?.status === 'timed_out'; @@ -504,12 +511,82 @@ export class SubagentTool implements ISubagentTool { ? `Agent timed out after ${formatSubagentTimeoutDescription(timeoutMs)}.` : formatSubagentStoppedMessage(info?.stopReason); return { - output: formatForegroundAgentFailure(handle, message, timedOut), + output: formatForegroundAgentFailure(handle, message, failureStopReason(info, stopCode)), isError: true, }; } } +type SubagentStopReason = + | 'completed' + | 'repeat_breaker' + | 'max_tokens' + | 'max_steps' + | 'filtered' + | 'provider_error' + | 'no_final_message' + | 'cancelled' + | 'timed_out' + | 'error'; + +const REASON_MAX_CHARS = 2000; + +const REPEAT_BREAKER_NOTICE = + 'notice: The subagent was stopped by the repeat breaker after issuing the same tool call repeatedly. The summary below is its handoff, not a finished result.'; + +function resumeHint(agentId: string, prompt: string): string { + return `resume_hint: Continue with Agent(resume="${agentId}", prompt="${prompt}"). Use agent_id only; do not set subagent_type. The subagent retains its prior context; redo any unfinished tool call if its result was lost.`; +} + +const RESUME_NEXT_STEP = + 'next_step: Resume to continue where it stopped, or take over the task yourself; if neither works, report the failure to the user.'; + +const NEXT_STEP_BY_REASON: Readonly> = { + completed: undefined, + repeat_breaker: + 'next_step: The subagent was stuck on one tool call. If you resume it, change the instructions or supply the missing input; otherwise continue the work yourself.', + cancelled: 'next_step: The user stopped this subagent. Do not restart it unless the user asks.', + filtered: + 'next_step: Resuming is unlikely to help; rephrase or split the task before trying again.', + max_tokens: RESUME_NEXT_STEP, + max_steps: RESUME_NEXT_STEP, + provider_error: RESUME_NEXT_STEP, + no_final_message: RESUME_NEXT_STEP, + timed_out: RESUME_NEXT_STEP, + error: RESUME_NEXT_STEP, +}; + +const STOP_REASON_BY_CODE: Readonly> = { + [ErrorCodes.AGENT_MAX_TOKENS_EXCEEDED]: 'max_tokens', + [ErrorCodes.LOOP_MAX_STEPS_EXCEEDED]: 'max_steps', + [ErrorCodes.PROVIDER_FILTERED]: 'filtered', + [ErrorCodes.PROVIDER_RATE_LIMIT]: 'provider_error', + [ErrorCodes.PROVIDER_API_ERROR]: 'provider_error', + [ErrorCodes.PROVIDER_OVERLOADED]: 'provider_error', + [ErrorCodes.PROVIDER_CONNECTION_ERROR]: 'provider_error', + [ErrorCodes.PROVIDER_AUTH_ERROR]: 'provider_error', + [ErrorCodes.AGENT_NO_FINAL_MESSAGE]: 'no_final_message', +}; + +function nextStep(reason: SubagentStopReason): string | undefined { + return NEXT_STEP_BY_REASON[reason]; +} + +function failureStopReason( + info: AgentTaskInfo | undefined, + stopCode: string | undefined, +): SubagentStopReason { + if (info?.status === 'timed_out') return 'timed_out'; + if (info?.status === 'killed') return 'cancelled'; + if (stopCode === undefined) return 'error'; + return STOP_REASON_BY_CODE[stopCode] ?? 'error'; +} + +function truncateReason(reason: string): string { + if (reason.length <= REASON_MAX_CHARS) return reason; + return `${reason.slice(0, REASON_MAX_CHARS)}… [truncated]`; +} + registerAgentToolService(ISubagentTool, SubagentTool, { name: 'Agent', domain: 'subagent', @@ -584,34 +661,42 @@ function formatBackgroundAgentResult( ].join('\n'); } -function formatForegroundAgentSuccess(handle: SubagentHandle, result: string): string { - return [ +function formatForegroundAgentSuccess( + handle: SubagentHandle, + result: string, + stopCode: string | undefined, +): string { + const reason: SubagentStopReason = + stopCode === REPEAT_BREAKER_STOP_REASON ? 'repeat_breaker' : 'completed'; + const lines = [ `agent_id: ${handle.agentId}`, `actual_subagent_type: ${handle.profileName}`, 'status: completed', - '', - '[summary]', - result, - ].join('\n'); + `stop_reason: ${reason}`, + ]; + if (reason === 'repeat_breaker') lines.push(REPEAT_BREAKER_NOTICE); + lines.push('', '[summary]', result, '', resumeHint(handle.agentId, '...')); + const next = nextStep(reason); + if (next !== undefined) lines.push(next); + return lines.join('\n'); } function formatForegroundAgentFailure( handle: SubagentHandle, message: string, - timedOut: boolean, + reason: SubagentStopReason, ): string { const lines = [ `agent_id: ${handle.agentId}`, `actual_subagent_type: ${handle.profileName}`, 'status: failed', + `stop_reason: ${reason}`, '', `subagent error: ${message}`, ]; - if (timedOut) { - lines.push( - `resume_hint: Continue with Agent(resume="${handle.agentId}", prompt="continue"). Use agent_id only; do not set subagent_type. The subagent retains its prior context; redo any unfinished tool call if its result was lost.`, - ); - } + if (reason !== 'cancelled') lines.push(resumeHint(handle.agentId, 'continue')); + const next = nextStep(reason); + if (next !== undefined) lines.push(next); return lines.join('\n'); } @@ -625,7 +710,7 @@ function formatSubagentStoppedMessage(reason: string | undefined): string { const normalized = reason?.trim(); if (normalized === userCancellationReason().message) return USER_INTERRUPTED_SUBAGENT_MESSAGE; if (normalized === undefined || normalized.length === 0) return SUBAGENT_STOPPED_MESSAGE; - return `${SUBAGENT_STOPPED_MESSAGE} Reason: ${normalized}`; + return `${SUBAGENT_STOPPED_MESSAGE} Reason: ${truncateReason(normalized)}`; } function errorMessage(error: unknown): string | undefined { diff --git a/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts b/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts index 4bc915a9b75..667bd7200ac 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts @@ -2,15 +2,21 @@ import type { TokenUsage } from '#/kosong/contract/usage'; import type { SubagentModelSource } from '#/session/subagent/configSection'; import { isAbortError } from '#/_base/utils/abort'; +import { isError2 } from '#/errors'; +import { REPEAT_BREAKER_STOP_REASON } from '#/agent/toolDedupe/toolDedupe'; import { type AgentTask, type AgentTaskInfoBase, type AgentTaskSink, } from '#/agent/task/types'; +const REPEAT_BREAKER_SETTLE_REASON = + 'stopped by the repeat breaker after issuing the same tool call repeatedly; its output is a handoff, not a finished result'; + type SubagentCompletion = { readonly result: string; readonly usage?: TokenUsage; + readonly stopReason?: string; }; export type SubagentHandle = { @@ -30,6 +36,7 @@ export interface SubagentTaskInfo extends AgentTaskInfoBase { readonly parentToolCallId?: string; readonly model?: string; readonly thinkingEffort?: string; + readonly stopCode?: string; } declare module '#/agent/task/types' { @@ -42,6 +49,14 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +function stopCodeOf(error: unknown): string | undefined { + return isError2(error) ? error.code : undefined; +} + +function completedSettleReason(stopReason: string | undefined): string | undefined { + return stopReason === REPEAT_BREAKER_STOP_REASON ? REPEAT_BREAKER_SETTLE_REASON : undefined; +} + export function createSubagentExecutor( handle: SubagentHandle, abortController: AbortController, @@ -79,6 +94,7 @@ export class SubagentTask implements AgentTask { readonly parentToolCallId?: string; readonly model?: string; readonly thinkingEffort?: string; + private stopCode: string | undefined; constructor( private readonly handle: SubagentHandle, @@ -104,13 +120,18 @@ export class SubagentTask implements AgentTask { try { const outcome = await this.handle.completion; + this.stopCode = outcome.stopReason; sink.appendOutput(outcome.result); - await sink.settle({ status: 'completed' }); + await sink.settle({ + status: 'completed', + stopReason: completedSettleReason(outcome.stopReason), + }); } catch (error: unknown) { if (sink.signal.aborted && (isAbortError(error) || error === sink.signal.reason)) { await sink.settle({ status: 'killed' }); return; } + this.stopCode = stopCodeOf(error); await sink.settle({ status: 'failed', stopReason: errorMessage(error) }); } finally { sink.signal.removeEventListener('abort', requestAbort); @@ -126,6 +147,7 @@ export class SubagentTask implements AgentTask { parentToolCallId: this.parentToolCallId, model: this.model, thinkingEffort: this.thinkingEffort, + stopCode: this.stopCode, }; } } diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts index 6ff7be2ba94..5dc153ace05 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -9,12 +9,6 @@ export interface AgentProfilePromptPrefixContext { readonly log?: ILogger; } -export interface AgentProfileSummaryPolicy { - readonly minChars: number; - readonly continuationPrompt: string; - readonly retries: number; -} - export interface AgentProfileContext { readonly cwd?: string; readonly cwdListing?: string; @@ -51,7 +45,6 @@ export interface AgentProfile { readonly systemPrompt: (context: AgentProfileContext) => string; readonly renderSystemPrompt: (context: AgentProfileContext) => SystemPromptRenderResult; readonly promptPrefix?: (ctx: AgentProfilePromptPrefixContext) => Promise; - readonly summaryPolicy?: AgentProfileSummaryPolicy; } export type AgentProfileInput = Omit & diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index fc16425751b..465cce28e37 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -342,6 +342,11 @@ export interface ToolCallTurnRepeatEvent { trace_id?: string; } +export interface ToolCallRepeatHandoffEvent { + turn_id?: number; + outcome: 'text' | 'vetoed'; +} + export interface AgentsMdReminderShownEvent { turn_id: number; tool_name: string; @@ -956,6 +961,14 @@ export const telemetryEventDefinitions = { 'Trace id of the LLM request that produced the repeated tool call; absent for non-Kimi protocols', }, }), + tool_call_repeat_handoff: defineAgentTelemetryEvent({ + owner: 'kimi-code', + comment: 'The text-only handoff step that follows a repeat-breaker force stop finished.', + properties: { + turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session; omitted when no turn is active', + outcome: 'Whether the model answered in text or its tool calls were vetoed', + }, + }), agents_md_reminder_shown: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'An AGENTS.md discovery reminder is queued for context injection after a tool call.', diff --git a/packages/agent-core-v2/src/features/tower/workerProfile.ts b/packages/agent-core-v2/src/features/tower/workerProfile.ts index d5e35bb69c1..9fe6c0a3e10 100644 --- a/packages/agent-core-v2/src/features/tower/workerProfile.ts +++ b/packages/agent-core-v2/src/features/tower/workerProfile.ts @@ -7,7 +7,6 @@ import { skillActiveFor, TASK_AGENT_ROLE_PREFIX, } from '#/app/agentProfileCatalog/profile-shared'; -import SUMMARY_CONTINUATION_PROMPT from '../../session/agentLifecycle/profile/summary-continuation.md?raw'; import { TOWER_WORKER_PROFILE } from './tower'; import TOWER_WORKER_ROLE_OVERLAY from './tower-worker-overlay.md?raw'; @@ -48,17 +47,11 @@ const CODER_ROLE = 'Your final message is the entire handoff — the parent sees nothing else from your run. ' + 'Make it technically complete: what you changed and why, the path of every file you touched, ' + 'how you verified the change (tests or commands run, with results), and anything left undone ' + - 'or worth follow-up. A final message of only a sentence or two is treated as too brief and ' + - 'sent back to you for expansion, costing an extra turn.'; + 'or worth follow-up. If you are stopped before finishing, the parent receives only what ' + + 'you have written so far, so keep the handoff current.'; const TOWER_WORKER_ROLE = `${CODER_ROLE}\n\n${TOWER_WORKER_ROLE_OVERLAY.trim()}`; -const DEFAULT_SUMMARY_POLICY = { - minChars: 200, - continuationPrompt: SUMMARY_CONTINUATION_PROMPT, - retries: 1, -} as const; - export const TOWER_WORKER_PROFILE_DEF: AgentProfile = normalizeAgentProfile({ name: TOWER_WORKER_PROFILE, description: @@ -71,5 +64,4 @@ export const TOWER_WORKER_PROFILE_DEF: AgentProfile = normalizeAgentProfile({ renderSystemPromptResult(TOWER_WORKER_ROLE, context, { skillActive: skillActiveFor(TOWER_WORKER_TOOLS), }), - summaryPolicy: DEFAULT_SUMMARY_POLICY, }); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 8bfb1ee4061..6de809fd90f 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -654,6 +654,7 @@ export * from '#/agent/loop/loop'; export * from '#/agent/loop/loopService'; export * from '#/agent/loop/loopContinuation'; export * from '#/agent/loop/loopContinuationService'; +export * from '#/agent/loop/handoffStep'; export * from '#/agent/interruptionReminder/interruptionReminder'; export * from '#/agent/interruptionReminder/interruptionReminderService'; export * from '#/agent/interruptionReminder/interruptionReminderOps'; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/errors.ts b/packages/agent-core-v2/src/session/agentLifecycle/errors.ts index e72baaf0d78..015d4bf3f46 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/errors.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/errors.ts @@ -9,6 +9,7 @@ export const AgentLifecycleErrors = { AGENT_NOT_OWNED: 'agent.not_owned', AGENT_TYPE_NOT_ALLOWED: 'agent.type_not_allowed', AGENT_MAX_TOKENS_EXCEEDED: 'agent.max_tokens_exceeded', + AGENT_NO_FINAL_MESSAGE: 'agent.no_final_message', }, } as const satisfies ErrorDomain; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts index c55af1164e2..58095e54f3b 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -7,7 +7,6 @@ import { } from '#/app/agentProfileCatalog/profile-shared'; import EXPLORE_ROLE from './explore-overlay.md?raw'; -import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md?raw'; const AGENT_TOOLS = [ 'Read', @@ -82,14 +81,8 @@ const CODER_ROLE = 'Your final message is the entire handoff — the parent sees nothing else from your run. ' + 'Make it technically complete: what you changed and why, the path of every file you touched, ' + 'how you verified the change (tests or commands run, with results), and anything left undone ' + - 'or worth follow-up. A final message of only a sentence or two is treated as too brief and ' + - 'sent back to you for expansion, costing an extra turn.'; - -const DEFAULT_SUMMARY_POLICY = { - minChars: 200, - continuationPrompt: SUMMARY_CONTINUATION_PROMPT, - retries: 1, -} as const; + 'or worth follow-up. If you are stopped before finishing, the parent receives only what ' + + 'you have written so far, so keep the handoff current.'; registerAgentProfile({ name: 'agent', @@ -109,7 +102,6 @@ registerAgentProfile({ tools: CODER_TOOLS, renderSystemPrompt: (context) => renderSystemPromptResult(CODER_ROLE, context, { skillActive: skillActiveFor(CODER_TOOLS) }), - summaryPolicy: DEFAULT_SUMMARY_POLICY, }); registerAgentProfile({ @@ -127,5 +119,4 @@ registerAgentProfile({ return ''; } }, - summaryPolicy: DEFAULT_SUMMARY_POLICY, }); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/summary-continuation.md b/packages/agent-core-v2/src/session/agentLifecycle/profile/summary-continuation.md deleted file mode 100644 index 8efb589a590..00000000000 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/summary-continuation.md +++ /dev/null @@ -1,5 +0,0 @@ -Your previous response was too brief. Please provide a more comprehensive summary that includes: - -1. Specific technical details and implementations -2. Detailed findings and analysis -3. All important information that the parent agent should know \ No newline at end of file diff --git a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts index 2a4e98d54a1..30d4acebaac 100644 --- a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts @@ -13,7 +13,7 @@ import { isAbortError } from '#/_base/utils/abort'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IEventDispatcher } from '#/state/eventDispatcher'; -import { type AgentRunHandle, ISessionSubagentService } from './subagent'; +import { type AgentRunCompletion, type AgentRunHandle, ISessionSubagentService } from './subagent'; import type { SubagentModelSource } from './configSection'; export interface SubagentSpawnedPayload { @@ -136,7 +136,7 @@ export async function mirrorAgentRun( requester: IAgentScopeHandle, run: AgentRunHandle, options: MirrorAgentRunOptions, -): Promise<{ summary: string; usage?: TokenUsage }> { +): Promise { const dispatcher = requester.accessor.get(IEventDispatcher); const subagents = requester.accessor.get(ISessionSubagentService); const agentLifecycle = requester.accessor.get(IAgentLifecycleService); diff --git a/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts b/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts index bbd42b5339a..4bb64c5f14f 100644 --- a/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts +++ b/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts @@ -1,5 +1,4 @@ import { APIProviderRateLimitError, isProviderRateLimitError } from '#/kosong/contract/errors'; -import { type TokenUsage } from '#/kosong/contract/usage'; import { linkAbortSignal, userCancellationReason } from '#/_base/utils/abort'; import type { IAgentScopeHandle } from '#/_base/di/scope'; @@ -7,12 +6,16 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; import { Error2, ErrorCodes, toKimiErrorPayload, type KimiErrorPayload } from '#/errors'; import { IAgentPromptService } from '#/agent/prompt/prompt'; -import { IAgentLoopService, type Turn, type TurnResult } from '#/agent/loop/loop'; +import { + IAgentLoopService, + isMaxStepsExceededError, + type Turn, + type TurnResult, +} from '#/agent/loop/loop'; import { agentContextOf } from '#/agent/scopeContext/scopeContext'; import { ISessionUsageService } from '#/session/usage/sessionUsage'; -import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog/agentProfileCatalog'; -import type { AgentRunHandle, AgentRunRequest } from './subagent'; +import type { AgentRunCompletion, AgentRunHandle, AgentRunRequest } from './subagent'; export const AGENT_RUN_PROMPT_ORIGIN: PromptOrigin = { kind: 'system_trigger', @@ -22,8 +25,9 @@ export const AGENT_RUN_PROMPT_ORIGIN: PromptOrigin = { const SUBAGENT_MAX_TOKENS_ERROR = 'Subagent turn failed before completing its final summary: reason=max_tokens'; +type CompletedTurnResult = Extract; + export interface RunAgentTurnOptions { - readonly summaryPolicy?: AgentProfileSummaryPolicy; readonly signal: AbortSignal; readonly onReady?: () => void; } @@ -58,32 +62,30 @@ async function awaitRun( target: IAgentScopeHandle, turn: Turn, options: RunAgentTurnOptions, -): Promise<{ summary: string; usage?: TokenUsage }> { +): Promise { const controller = new AbortController(); const unlink = linkAbortSignal(options.signal, controller); const loop = target.accessor.get(IAgentLoopService); - const cancelTurn = (turnToCancel: Turn, reason: unknown): void => { - loop.cancel(turnToCancel.id, reason); + const cancelTurn = (reason: unknown): void => { + loop.cancel(turn.id, reason); }; - let turnRef: Turn = turn; try { - const result = await awaitTurn(turnRef, controller, cancelTurn); - classifyTurnResult(result); - const summary = await distillSummary( - target, - controller, - options.summaryPolicy, - (t) => { - turnRef = t; - }, - cancelTurn, - ); + const result = classifyTurnResult(await awaitTurn(turn, controller, cancelTurn)); + const summary = latestAssistantText(target.accessor.get(IAgentContextMemoryService).get()); + const stopReason = result.stopReason; + if (summary.trim().length === 0) { + throw new Error2( + ErrorCodes.AGENT_NO_FINAL_MESSAGE, + noFinalMessageError(stopReason), + stopReason === undefined ? undefined : { details: { stopReason } }, + ); + } const usage = target.accessor.get(ISessionUsageService)?.status(agentContextOf(target)).total; - return { summary, usage }; + return { summary, usage, stopReason }; } finally { unlink(); if (controller.signal.aborted) { - cancelTurn(turnRef, controller.signal.reason); + cancelTurn(controller.signal.reason); } } } @@ -91,10 +93,10 @@ async function awaitRun( async function awaitTurn( turn: Turn, controller: AbortController, - cancelTurn: (turn: Turn, reason: unknown) => void, + cancelTurn: (reason: unknown) => void, ): Promise { const cancelOnAbort = (): void => { - cancelTurn(turn, controller.signal.reason); + cancelTurn(controller.signal.reason); }; controller.signal.addEventListener('abort', cancelOnAbort, { once: true }); try { @@ -109,48 +111,13 @@ async function awaitTurn( } } -async function distillSummary( - target: IAgentScopeHandle, - controller: AbortController, - policy: AgentProfileSummaryPolicy | undefined, - setTurn: (turn: Turn) => void, - cancelTurn: (turn: Turn, reason: unknown) => void, -): Promise { - const memory = target.accessor.get(IAgentContextMemoryService); - let summary = latestAssistantText(memory.get()); - if (policy === undefined) return summary; - if (isSummaryAdequate(summary, policy)) return summary; - - const promptService = target.accessor.get(IAgentPromptService); - for (let attempt = 0; attempt < policy.retries; attempt++) { - const turn = await (await promptService.enqueue({ message: { - role: 'user', - content: [{ type: 'text', text: policy.continuationPrompt }], - toolCalls: [], - origin: AGENT_RUN_PROMPT_ORIGIN, - } })).launched; - if (turn === undefined) break; - setTurn(turn); - const result = await awaitTurn(turn, controller, cancelTurn); - classifyTurnResult(result); - const continued = latestAssistantText(memory.get()); - if (continued.trim().length > 0) summary = continued; - if (isSummaryAdequate(summary, policy)) break; - } - return summary; -} - -function isSummaryAdequate(summary: string, policy: AgentProfileSummaryPolicy): boolean { - return summary.trim().length >= policy.minChars; -} - -function classifyTurnResult(result: TurnResult): void { +function classifyTurnResult(result: TurnResult): CompletedTurnResult { switch (result.type) { case 'completed': if (result.truncated) { throw new Error2(ErrorCodes.AGENT_MAX_TOKENS_EXCEEDED, SUBAGENT_MAX_TOKENS_ERROR); } - return; + return result; case 'failed': { const error = result.error; if (isProviderRateLimitError(error)) throw error; @@ -158,6 +125,9 @@ function classifyTurnResult(result: TurnResult): void { if (payload.code === ErrorCodes.PROVIDER_RATE_LIMIT) { throw providerRateLimitErrorFromPayload(payload); } + if (isMaxStepsExceededError(error)) { + throw maxStepsErrorFromPayload(payload); + } throw toRunError(error); } case 'cancelled': @@ -165,6 +135,21 @@ function classifyTurnResult(result: TurnResult): void { } } +function noFinalMessageError(stopReason: string | undefined): string { + const base = 'Subagent turn ended without a final message'; + return stopReason === undefined ? `${base}.` : `${base} (stop reason: ${stopReason}).`; +} + +function maxStepsErrorFromPayload(payload: KimiErrorPayload): Error2 { + const maxSteps = payload.details?.['maxSteps']; + const cap = typeof maxSteps === 'number' ? ` (maxSteps=${String(maxSteps)})` : ''; + return new Error2( + ErrorCodes.LOOP_MAX_STEPS_EXCEEDED, + `Subagent hit the per-turn step cap${cap} before finishing its handoff.`, + typeof maxSteps === 'number' ? { details: { maxSteps } } : undefined, + ); +} + function toRunError(error: unknown): Error { if (error instanceof Error) return error; if (error === undefined || error === null) return new Error('Agent turn failed'); diff --git a/packages/agent-core-v2/src/session/subagent/subagent.ts b/packages/agent-core-v2/src/session/subagent/subagent.ts index d3994abc5eb..d07baf41eab 100644 --- a/packages/agent-core-v2/src/session/subagent/subagent.ts +++ b/packages/agent-core-v2/src/session/subagent/subagent.ts @@ -1,7 +1,6 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { Event } from '#/_base/event'; import type { TokenUsage } from '#/kosong/contract/usage'; -import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog/agentProfileCatalog'; import type { AgentContext } from '#/agent/agentContext/agentContext'; import type { Turn } from '#/agent/loop/loop'; import type { Hooks } from '#/hooks'; @@ -19,14 +18,19 @@ export type AgentRunRequest = export interface RunAgentOptions { readonly signal: AbortSignal; - readonly summaryPolicy?: AgentProfileSummaryPolicy; readonly onReady?: () => void; } +export interface AgentRunCompletion { + readonly summary: string; + readonly usage?: TokenUsage; + readonly stopReason?: string; +} + export interface AgentRunHandle { readonly agentId: string; readonly turn: Turn; - readonly completion: Promise<{ readonly summary: string; readonly usage?: TokenUsage }>; + readonly completion: Promise; } export interface AgentTaskStartHookContext { diff --git a/packages/agent-core-v2/src/session/subagent/subagentService.ts b/packages/agent-core-v2/src/session/subagent/subagentService.ts index ee2eff33112..d4b48ef51ec 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentService.ts @@ -8,7 +8,6 @@ import { registerScopedService, } from '#/_base/di/scope'; import { Emitter } from '#/_base/event'; -import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { applyProfilePromptPrefix } from '#/app/agentProfileCatalog/promptPrefix'; import { rootDelegationExtras, @@ -87,11 +86,7 @@ export class SessionSubagentService extends Service implements ISessionSubagentS details: { agentId: agent.agentId }, }); } - return runAgentTurn(handle, request, { - summaryPolicy: opts.summaryPolicy ?? this.summaryPolicyFor(handle), - signal: opts.signal, - onReady: opts.onReady, - }); + return runAgentTurn(handle, request, { signal: opts.signal, onReady: opts.onReady }); } async planSpawn(input: SubagentSpawnPlanInput): Promise { @@ -245,12 +240,6 @@ export class SessionSubagentService extends Service implements ISessionSubagentS } return handle; } - - private summaryPolicyFor(handle: IAgentScopeHandle): AgentProfileSummaryPolicy | undefined { - const profileName = handle.accessor.get(IAgentProfileService).data().profileName; - if (profileName === undefined) return undefined; - return this.catalog.get(profileName)?.summaryPolicy; - } } registerScopedService( diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index b368b2aa3b4..430ced66938 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -33,6 +33,7 @@ export interface ExecutableToolSuccessResult { readonly output: ExecutableToolOutput; readonly isError?: false | undefined; readonly stopTurn?: boolean | undefined; + readonly stopTurnReason?: string; readonly truncated?: boolean | undefined; readonly note?: string; readonly delivery?: ToolDelivery | undefined; @@ -44,6 +45,7 @@ export interface ExecutableToolErrorResult { readonly output: ExecutableToolOutput; readonly isError: true; readonly stopTurn?: boolean | undefined; + readonly stopTurnReason?: string; readonly truncated?: boolean | undefined; readonly note?: string; readonly delivery?: ToolDelivery | undefined; diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 346d6908bb4..6abd5da7792 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -631,6 +631,46 @@ describe('Agent loop', () => { }); }); + it('carries a tool stopTurnReason into the completed turn result and turn.ended', async () => { + const stopCall: ToolCall = { + type: 'function', + id: 'call_stop', + name: 'Stopper', + arguments: '{}', + }; + const stopperTool: ExecutableTool> = { + name: 'Stopper', + description: 'Stops the turn with a reason.', + parameters: { type: 'object', properties: {}, additionalProperties: false }, + resolveExecution: () => ({ + approvalRule: 'Stopper', + execute: async () => ({ output: 'stopped', stopTurn: true, stopTurnReason: 'demo_reason' }), + }), + }; + profile.update({ activeToolNames: ['Stopper'] }); + ctx.get(IAgentToolRegistryService).register(stopperTool); + + ctx.mockNextResponse({ type: 'text', text: 'Stopping.' }, stopCall); + ctx.mockNextResponse({ type: 'text', text: 'This step should not run.' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'stop' }] }); + const turn = (loop as unknown as { activeTurnJob?: { turn: Turn } }).activeTurnJob?.turn; + await ctx.untilApproval(true); + await ctx.untilTurnEnd(); + + expect(ctx.llmCalls).toHaveLength(1); + await expect(turn!.result).resolves.toEqual({ + type: 'completed', + steps: 1, + truncated: false, + stopReason: 'demo_reason', + }); + const turnEnded = ctx.allEvents.find( + (event) => event.type === '[rpc]' && event.event === 'turn.ended', + ); + expect(turnEnded?.args).toMatchObject({ reason: 'completed', stopReason: 'demo_reason' }); + }); + it('queues consecutive nextTurn requests in FIFO order without overlapping turns', async () => { const events: string[] = []; const subscription = ctx.get(IEventBus).subscribe((event) => { diff --git a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts index 8515db2a3bc..ebf88ac7de4 100644 --- a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts +++ b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts @@ -10,7 +10,7 @@ import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import type { IHostProcessService } from '#/os/interface/hostProcess'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentLoopService, type Turn } from '#/agent/loop/loop'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; @@ -24,7 +24,7 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; import { registerLogServices } from '../../_base/log/stubs'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; -import { stubLoopWithHooks } from '../loop/stubs'; +import { stubLoopWithHooks, type StubLoop } from '../loop/stubs'; import { stubToolExecutorEvents } from '../toolExecutor/stubs'; import { registerToolResultTruncationServices } from '../toolResultTruncation/stubs'; import { registerTestAgentWireServices } from '../../wire/stubs'; @@ -53,7 +53,7 @@ afterEach(() => disposables.dispose()); interface Harness { readonly ix: TestInstantiationService; - readonly loop: IAgentLoopService; + readonly loop: StubLoop; readonly executor: IAgentToolExecutorService; readonly registry: IAgentToolRegistryService; readonly fireBefore: ( @@ -673,6 +673,91 @@ describe('AgentToolDedupeService', () => { }); }); + describe('repeat breaker handoff step', () => { + const { REPEAT_BREAKER_STOP_REASON, HANDOFF_VETO_TEXT } = toolDedupeTesting; + + async function runStreak(h: Harness, count: number): Promise { + let last: ToolResult | undefined; + for (let i = 0; i < count; i += 1) { + const [result] = await runStep(h, 1, i + 1, [toolCall(`c${String(i)}`, 'Read', { p: 1 })]); + last = result!.result; + } + return last!; + } + + function drainHandoff(h: Harness): string | undefined { + return h.loop.drainNextBatch({ append: () => {} })?.driver.kind; + } + + it('tags the force-stop result with the repeat_breaker stop reason', async () => { + const h = createHarness(); + h.registry.register(new EchoTool('Read')); + const last = await runStreak(h, 12); + expect(last.stopTurn).toBe(true); + expect(last.stopTurnReason).toBe(REPEAT_BREAKER_STOP_REASON); + }); + + it('enqueues a single handoff step after the force stop', async () => { + const h = createHarness(); + h.registry.register(new EchoTool('Read')); + await runStreak(h, 11); + expect(h.loop.queue.hasPendingRequests()).toBe(false); + await runStep(h, 1, 12, [toolCall('c11', 'Read', { p: 1 })]); + expect(drainHandoff(h)).toBe('handoff'); + expect(h.loop.queue.hasPendingRequests()).toBe(false); + }); + + it('vetoes tool calls during the handoff step and ends the turn with the same reason', async () => { + const h = createHarness(); + const tool = new EchoTool('Read'); + h.registry.register(tool); + await runStreak(h, 12); + expect(drainHandoff(h)).toBe('handoff'); + + const [vetoed] = await runStep(h, 1, 13, [toolCall('c12', 'Read', { p: 2 })]); + expect(vetoed!.result).toMatchObject({ + isError: true, + stopTurn: true, + stopTurnReason: REPEAT_BREAKER_STOP_REASON, + }); + expect(vetoed!.result.output as string).toContain(HANDOFF_VETO_TEXT); + expect(tool.calls).toHaveLength(12); + expect(h.loop.queue.hasPendingRequests()).toBe(false); + expect( + telemetryEvents.find((e) => e.event === 'tool_call_repeat_handoff')?.properties, + ).toMatchObject({ turn_id: 1, outcome: 'vetoed' }); + expect( + telemetryEvents.filter( + (e) => e.event === 'tool_call_repeat' && e.properties?.['repeat_count'] === 13, + ), + ).toHaveLength(0); + }); + + it('records a text handoff when the model answers without tool calls', async () => { + const h = createHarness(); + h.registry.register(new EchoTool('Read')); + await runStreak(h, 12); + expect(drainHandoff(h)).toBe('handoff'); + await runStep(h, 1, 13, []); + expect(h.loop.queue.hasPendingRequests()).toBe(false); + expect( + telemetryEvents.find((e) => e.event === 'tool_call_repeat_handoff')?.properties, + ).toMatchObject({ turn_id: 1, outcome: 'text' }); + }); + + it('allows a fresh handoff in the next turn', async () => { + const h = createHarness(); + h.registry.register(new EchoTool('Read')); + await runStreak(h, 12); + expect(drainHandoff(h)).toBe('handoff'); + await runStep(h, 1, 13, []); + for (let i = 0; i < 12; i += 1) { + await runStep(h, 2, i + 1, [toolCall(`t2-${String(i)}`, 'Read', { p: 1 })]); + } + expect(drainHandoff(h)).toBe('handoff'); + }); + }); + describe('repeat telemetry', () => { it('emits same-step duplicate detection telemetry', async () => { const h = createHarness(); @@ -1048,17 +1133,58 @@ describe('AgentToolDedupeService', () => { for (let i = 0; i < 12; i += 1) { ctx.mockNextResponse(invalidBashCallWithId(`call_bad_${String(i)}`)); } + ctx.mockNextResponse({ type: 'text', text: 'Handoff: the bash call keeps failing validation.' }); ctx.mockNextResponse({ type: 'text', text: 'must never be generated' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Repeat the bad call' }] }); + const turn = (ctx.get(IAgentLoopService) as unknown as { activeTurnJob?: { turn: Turn } }) + .activeTurnJob?.turn; await ctx.untilTurnEnd(); expect(exec).not.toHaveBeenCalled(); - expect(ctx.llmCalls).toHaveLength(12); + expect(ctx.llmCalls).toHaveLength(13); const actions = records .filter((entry) => entry.event === 'tool_call_repeat') .map((entry) => entry.properties?.['action']); expect(actions).toEqual(['none', 'r1', 'r1', 'r2', 'r2', 'r2', 'r3', 'r3', 'r3', 'r3', 'stop']); + await expect(turn!.result).resolves.toMatchObject({ + type: 'completed', + stopReason: 'repeat_breaker', + }); + expect( + records.find((entry) => entry.event === 'tool_call_repeat_handoff')?.properties, + ).toMatchObject({ outcome: 'text' }); + }); + + it('vetoes a tool call issued during the handoff step and still ends the turn', async () => { + const records: TelemetryRecord[] = []; + const { ctx, exec } = rejectedBashAgent(records); + + for (let i = 0; i < 13; i += 1) { + ctx.mockNextResponse(invalidBashCallWithId(`call_bad_${String(i)}`)); + } + ctx.mockNextResponse({ type: 'text', text: 'must never be generated' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Repeat the bad call' }] }); + const turn = (ctx.get(IAgentLoopService) as unknown as { activeTurnJob?: { turn: Turn } }) + .activeTurnJob?.turn; + await ctx.untilTurnEnd(); + + expect(exec).not.toHaveBeenCalled(); + expect(ctx.llmCalls).toHaveLength(13); + await expect(turn!.result).resolves.toMatchObject({ + type: 'completed', + stopReason: 'repeat_breaker', + }); + expect( + records.find((entry) => entry.event === 'tool_call_repeat_handoff')?.properties, + ).toMatchObject({ outcome: 'vetoed' }); + expect( + records.filter( + (entry) => + entry.event === 'tool_call_repeat' && entry.properties?.['repeat_count'] === 13, + ), + ).toHaveLength(0); }); it('does not force-stop when the malformed argument text keeps changing', async () => { diff --git a/packages/agent-core-v2/test/features/tower/workerProfile.test.ts b/packages/agent-core-v2/test/features/tower/workerProfile.test.ts index 7f92a6dd544..f699dbb1577 100644 --- a/packages/agent-core-v2/test/features/tower/workerProfile.test.ts +++ b/packages/agent-core-v2/test/features/tower/workerProfile.test.ts @@ -36,10 +36,8 @@ describe('tower-worker profile', () => { } }); - it('keeps the coder summary policy and whenToUse', () => { + it('keeps the coder whenToUse', () => { const coder = builtinProfile('coder'); - expect(TOWER_WORKER_PROFILE_DEF.summaryPolicy).toEqual(coder.summaryPolicy); - expect(TOWER_WORKER_PROFILE_DEF.summaryPolicy).toBeDefined(); expect(TOWER_WORKER_PROFILE_DEF.whenToUse).toBe(coder.whenToUse); }); }); diff --git a/packages/agent-core-v2/test/session/subagent/forkParity.test.ts b/packages/agent-core-v2/test/session/subagent/forkParity.test.ts index dc104ec99aa..a7bcd0b3f7d 100644 --- a/packages/agent-core-v2/test/session/subagent/forkParity.test.ts +++ b/packages/agent-core-v2/test/session/subagent/forkParity.test.ts @@ -112,10 +112,7 @@ class TestRuntimeResolver implements IRuntimeResolver { const PARENT_SYSTEM_PROMPT = 'You are the parity probe parent.'; const ACTIVE_TOOL_NAMES = ['Agent', 'Bash', 'Read']; -const CHILD_FINAL_TEXT = - 'The inherited task is done. This closing summary is intentionally long so that any ' + - 'profile summary policy with a minimum character threshold considers it adequate and no ' + - 'extra continuation request is scripted for the child agent turn.'; +const CHILD_FINAL_TEXT = 'The inherited task is done.'; describe('fork subagent first-request parity', () => { let ctx: TestAgentContext; diff --git a/packages/agent-core-v2/test/session/subagent/runAgentTurn.test.ts b/packages/agent-core-v2/test/session/subagent/runAgentTurn.test.ts new file mode 100644 index 00000000000..336905ffbfb --- /dev/null +++ b/packages/agent-core-v2/test/session/subagent/runAgentTurn.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; + +import type { IAgentScopeHandle } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { + createMaxStepsExceededError, + IAgentLoopService, + type Turn, + type TurnResult, +} from '#/agent/loop/loop'; +import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { ErrorCodes, isError2 } from '#/errors'; +import { runAgentTurn } from '#/session/subagent/runAgentTurn'; + +function makeTurn(result: TurnResult): Turn { + const controller = new AbortController(); + return { + id: 1, + signal: controller.signal, + ready: Promise.resolve(), + result: Promise.resolve(result), + cancel: () => { + controller.abort(); + return true; + }, + }; +} + +function assistant(text: string): ContextMessage { + return { role: 'assistant', content: [{ type: 'text', text }], toolCalls: [] }; +} + +function handleWith( + turn: Turn, + messages: readonly ContextMessage[], +): { readonly handle: IAgentScopeHandle; readonly prompts: unknown[] } { + const prompts: unknown[] = []; + const services = new Map([ + [ + IAgentPromptService, + { + enqueue: async (input: unknown) => { + prompts.push(input); + return { launched: Promise.resolve(turn) }; + }, + retry: async () => turn, + }, + ], + [IAgentLoopService, { cancel: () => true }], + [IAgentContextMemoryService, { get: () => messages }], + ]); + const handle: IAgentScopeHandle = { + id: 'agent-child', + kind: LifecycleScope.Agent, + accessor: { + get: ((id: unknown) => services.get(id)) as IAgentScopeHandle['accessor']['get'], + }, + dispose: () => {}, + }; + return { handle, prompts }; +} + +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + throw new Error('expected rejection'); +} + +describe('runAgentTurn', () => { + const signal = new AbortController().signal; + + it('returns a short final message as is without a continuation prompt', async () => { + const { handle, prompts } = handleWith( + makeTurn({ type: 'completed', steps: 1, truncated: false }), + [assistant('src/a.ts:12')], + ); + const run = await runAgentTurn(handle, { kind: 'prompt', prompt: 'find it' }, { signal }); + await expect(run.completion).resolves.toMatchObject({ summary: 'src/a.ts:12' }); + expect(prompts).toHaveLength(1); + }); + + it('carries the loop stop reason alongside the handoff text', async () => { + const { handle } = handleWith( + makeTurn({ type: 'completed', steps: 13, truncated: false, stopReason: 'repeat_breaker' }), + [assistant('Stuck: the same grep keeps returning nothing.')], + ); + const run = await runAgentTurn(handle, { kind: 'prompt', prompt: 'find it' }, { signal }); + await expect(run.completion).resolves.toMatchObject({ + summary: 'Stuck: the same grep keeps returning nothing.', + stopReason: 'repeat_breaker', + }); + }); + + it('fails with agent.no_final_message when the turn ends without text', async () => { + const { handle } = handleWith( + makeTurn({ type: 'completed', steps: 13, truncated: false, stopReason: 'repeat_breaker' }), + [assistant('')], + ); + const run = await runAgentTurn(handle, { kind: 'prompt', prompt: 'find it' }, { signal }); + const error = await rejection(run.completion); + expect(isError2(error) && error.code).toBe(ErrorCodes.AGENT_NO_FINAL_MESSAGE); + expect((error as Error).message).toContain('stop reason: repeat_breaker'); + }); + + it('rewrites the step-cap failure into a model-facing message', async () => { + const { handle } = handleWith( + makeTurn({ type: 'failed', steps: 5, error: createMaxStepsExceededError(5) }), + [assistant('partial work')], + ); + const run = await runAgentTurn(handle, { kind: 'prompt', prompt: 'find it' }, { signal }); + const error = await rejection(run.completion); + expect(isError2(error) && error.code).toBe(ErrorCodes.LOOP_MAX_STEPS_EXCEEDED); + expect((error as Error).message).toContain('maxSteps=5'); + expect((error as Error).message).not.toContain('config.toml'); + }); + + it('still reports max tokens as a failure', async () => { + const { handle } = handleWith( + makeTurn({ type: 'completed', steps: 1, truncated: true }), + [assistant('cut off')], + ); + const run = await runAgentTurn(handle, { kind: 'prompt', prompt: 'find it' }, { signal }); + const error = await rejection(run.completion); + expect(isError2(error) && error.code).toBe(ErrorCodes.AGENT_MAX_TOKENS_EXCEEDED); + }); +}); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 4adefe5a929..9a34bc322cf 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -44,6 +44,8 @@ import { IAgentReminderService } from '#/features/reminder/reminderService'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, SECONDARY_MODEL_SECTION, SUBAGENT_SECTION } from '#/session/subagent/configSection'; import { SUBAGENT_FORK_FLAG_ID } from '#/session/subagent/flag'; import { Error2, ErrorCodes } from '#/errors'; +import type { AgentTaskSettlement } from '#/agent/task/types'; +import { SubagentTask } from '#/agent/tools/agent/subagent-task'; import { runAgentTurn } from '#/session/subagent/runAgentTurn'; import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; import type { AgentContext } from '#/agent/agentContext/agentContext'; @@ -52,6 +54,7 @@ import { type AgentScopeCreatedEvent, } from '#/session/agentLifecycle/agentLifecycle'; import { + type AgentRunCompletion, type AgentRunHandle, type AgentRunRequest, type AgentTaskStopHookContext, @@ -211,7 +214,7 @@ interface AgentLifecycleStubOptions { agentId: string, request: AgentRunRequest, options: RunAgentOptions, - ) => Promise<{ readonly summary: string; readonly usage?: TokenUsage }>; + ) => Promise; readonly createError?: Error; readonly handleServices?: ReadonlyMap>; } @@ -1337,6 +1340,177 @@ describe('Agent tool execution contract', () => { expect(result.output).toContain('actual_subagent_type: explore'); }); + it('reports a normal completion with stop_reason and a resume hint', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => ({ summary: 'child result' }), + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(result.isError).toBeUndefined(); + expect(result.output).toContain('status: completed'); + expect(result.output).toContain('stop_reason: completed'); + expect(result.output).toContain('[summary]\nchild result'); + expect(result.output).toContain('resume_hint: Continue with Agent(resume="agent-child"'); + expect(result.output).not.toContain('notice:'); + expect(result.output).not.toContain('next_step:'); + }); + + it('reports a repeat-breaker handoff as completed with stop_reason repeat_breaker', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => ({ + summary: 'Stuck: the same grep keeps returning nothing.', + stopReason: 'repeat_breaker', + }), + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(result.isError).toBeUndefined(); + expect(result.output).toContain('status: completed'); + expect(result.output).toContain('stop_reason: repeat_breaker'); + expect(result.output).toContain('notice: The subagent was stopped by the repeat breaker'); + expect(result.output).toContain('[summary]\nStuck: the same grep keeps returning nothing.'); + expect(result.output).toContain('next_step: The subagent was stuck on one tool call.'); + }); + + it('settles a repeat-breaker completion with a stop code and a task reason', async () => { + const task = new SubagentTask( + { + agentId: 'agent-child', + profileName: 'coder', + completion: Promise.resolve({ result: 'handoff', stopReason: 'repeat_breaker' }), + }, + 'Find cause', + new AbortController(), + ); + const settlements: AgentTaskSettlement[] = []; + const output: string[] = []; + await task.start({ + signal: new AbortController().signal, + appendOutput: (chunk) => { + output.push(chunk); + }, + settle: async (settlement) => { + settlements.push(settlement); + return true; + }, + }); + + expect(output).toEqual(['handoff']); + expect(settlements).toEqual([ + { status: 'completed', stopReason: expect.stringContaining('repeat breaker') }, + ]); + const info = task.toInfo({ + taskId: 'agent-1', + description: 'Find cause', + status: 'completed', + startedAt: 0, + endedAt: 1, + }); + expect(info.stopCode).toBe('repeat_breaker'); + }); + + it('reports a missing final message as a failure with stop_reason no_final_message', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => { + throw new Error2( + ErrorCodes.AGENT_NO_FINAL_MESSAGE, + 'Subagent turn ended without a final message (stop reason: repeat_breaker).', + ); + }, + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('status: failed'); + expect(result.output).toContain('stop_reason: no_final_message'); + expect(result.output).toContain( + 'subagent error: The subagent was stopped before it finished. Reason: Subagent turn ended without a final message (stop reason: repeat_breaker).', + ); + expect(result.output).toContain('resume_hint: Continue with Agent(resume="agent-child", prompt="continue")'); + expect(result.output).toContain('next_step: Resume to continue where it stopped'); + }); + + it('maps a step-cap failure to stop_reason max_steps without config advice', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => { + throw new Error2( + ErrorCodes.LOOP_MAX_STEPS_EXCEEDED, + 'Subagent hit the per-turn step cap (maxSteps=5) before finishing its handoff.', + ); + }, + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('stop_reason: max_steps'); + expect(result.output).toContain('maxSteps=5'); + expect(result.output).not.toContain('config.toml'); + expect(result.output).toContain('resume_hint:'); + }); + + it('maps a provider filter failure to stop_reason filtered with a rephrase hint', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => { + throw new Error2(ErrorCodes.PROVIDER_FILTERED, 'Provider safety policy blocked the response.'); + }, + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('stop_reason: filtered'); + expect(result.output).toContain('next_step: Resuming is unlikely to help'); + }); + + it('truncates an oversized failure reason', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => { + throw new Error('x'.repeat(5000)); + }, + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('stop_reason: error'); + expect(result.output).toContain('[truncated]'); + expect((result.output as string).length).toBeLessThan(3000); + }); + it('declares no resource accesses so concurrent Agent calls can run in parallel', async () => { const context = createAgentToolContext(); @@ -2600,7 +2774,10 @@ describe('Agent tool execution contract', () => { expect(result.isError).toBe(true); expect(result.output).toContain('status: failed'); + expect(result.output).toContain('stop_reason: cancelled'); expect(result.output).toContain('The subagent was stopped before it finished by user.'); + expect(result.output).not.toContain('resume_hint:'); + expect(result.output).toContain('next_step: The user stopped this subagent.'); }); it('reports the reason when a foreground subagent is stopped for another cause', async () => { From cf7c5ebc1cc99d557f0ce718a68ec96b852c5f14 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 2 Sep 2026 14:52:42 +0800 Subject: [PATCH 2/4] chore(agent-core-v2): regenerate the state manifest after merging main --- packages/agent-core-v2/docs/state-manifest.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 703e750d703..0b15f9d5b54 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -27,7 +27,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 83 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 84 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts From 59680667c7d6ac6ff404433934a3dae554f200df Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 2 Sep 2026 16:32:12 +0800 Subject: [PATCH 3/4] fix(agent-core-v2): run the handoff step at the step cap and surface swarm stop reasons - The per-turn step cap no longer skips a queued handoff step, so a forced stop that lands on the last permitted step still produces the text handoff instead of a max_steps failure. - AgentSwarm results carry the worker's stop reason and render it as a stop_reason attribute; a stuck worker now also triggers the resume hint. --- .../src/agent/loop/loopService.ts | 8 ++- .../src/agent/loop/stepRequestQueue.ts | 6 +++ .../features/swarm/session/agentRunBatch.ts | 2 + .../features/swarm/session/sessionSwarm.ts | 1 + .../swarm/session/sessionSwarmService.ts | 6 ++- .../swarm/tools/agent-swarm/agentSwarmTool.ts | 7 ++- .../test/agent/toolDedupe/toolDedupe.test.ts | 48 +++++++++++++++++- packages/agent-core-v2/test/tool/tool.test.ts | 50 +++++++++++++++++++ 8 files changed, 123 insertions(+), 5 deletions(-) diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 27c408ae0bd..7742d726334 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -56,6 +56,7 @@ import { type TurnSeed, } from './stepRequest'; import { StepRequestQueue, type StepRequestBatch } from './stepRequestQueue'; +import { HANDOFF_STEP_KIND } from './handoffStep'; import { AssistantDelta, isDisplayablePromptOrigin, @@ -701,7 +702,12 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { return { result: this.completedResult(runtime) }; } const maxSteps = this.config.get(LOOP_CONTROL_SECTION)?.maxStepsPerTurn; - if (maxSteps !== undefined && maxSteps > 0 && runtime.steps >= maxSteps) { + if ( + maxSteps !== undefined && + maxSteps > 0 && + runtime.steps >= maxSteps && + runtime.queue.peekDriverKind() !== HANDOFF_STEP_KIND + ) { throw createMaxStepsExceededError(maxSteps); } const batch = runtime.queue.takeNextBatch()!; diff --git a/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts b/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts index b8aa03ae409..42721ddb8e1 100644 --- a/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts +++ b/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts @@ -20,6 +20,12 @@ export class StepRequestQueue { return this.items.some((item) => !item.aborted); } + peekDriverKind(): string | undefined { + this.discardAborted(); + const driver = this.items.find((item) => !item.mergeable) ?? this.items[0]; + return driver?.kind; + } + takeNextBatch(): StepRequestBatch | undefined { this.discardAborted(); if (this.items.length === 0) return undefined; diff --git a/packages/agent-core-v2/src/features/swarm/session/agentRunBatch.ts b/packages/agent-core-v2/src/features/swarm/session/agentRunBatch.ts index dbc28999771..75443eb24f5 100644 --- a/packages/agent-core-v2/src/features/swarm/session/agentRunBatch.ts +++ b/packages/agent-core-v2/src/features/swarm/session/agentRunBatch.ts @@ -32,6 +32,7 @@ export type AgentRunAttemptHandle = { readonly completion: Promise<{ readonly result: string; readonly usage?: TokenUsage; + readonly stopReason?: string; }>; }; @@ -311,6 +312,7 @@ export class AgentRunBatch { status: 'completed', result: completion.result, usage: completion.usage, + stopReason: completion.stopReason, }; } catch (error) { if (isProviderRateLimitError(error)) { diff --git a/packages/agent-core-v2/src/features/swarm/session/sessionSwarm.ts b/packages/agent-core-v2/src/features/swarm/session/sessionSwarm.ts index 986ada8dabf..6ad754ad9a8 100644 --- a/packages/agent-core-v2/src/features/swarm/session/sessionSwarm.ts +++ b/packages/agent-core-v2/src/features/swarm/session/sessionSwarm.ts @@ -42,6 +42,7 @@ export interface SessionSwarmRunResult { readonly state?: 'started' | 'not_started'; readonly result?: string; readonly usage?: TokenUsage; + readonly stopReason?: string; readonly error?: string; } diff --git a/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts index 74ae9af37dc..9bc8b30d317 100644 --- a/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts @@ -193,7 +193,11 @@ export class SessionSwarmService implements ISessionSwarmService { return { agentId, profileName, - completion: mirrored.then((r) => ({ result: r.summary, usage: r.usage })), + completion: mirrored.then((r) => ({ + result: r.summary, + usage: r.usage, + stopReason: r.stopReason, + })), }; } diff --git a/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts index 997fd27549e..512a5634c63 100644 --- a/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts +++ b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts @@ -65,6 +65,7 @@ interface SwarmRunResult { readonly status: 'completed' | 'failed' | 'aborted'; readonly state?: 'started' | 'not_started'; readonly result?: string; + readonly stopReason?: string; readonly error?: string; } @@ -301,7 +302,7 @@ function renderSwarmResults(results: readonly SwarmRunResult[]): string { const failed = results.filter((result) => result.status === 'failed').length; const aborted = results.filter((result) => result.status === 'aborted').length; const shouldRenderResumeHint = - results.some((result) => result.status !== 'completed') && + results.some((result) => result.status !== 'completed' || result.stopReason !== undefined) && results.some((result) => result.agentId !== undefined); const lines = [ '', @@ -319,9 +320,11 @@ function renderSwarmResults(results: readonly SwarmRunResult[]): string { const mode = result.spec.kind === 'resume' ? ' mode="resume"' : ''; const item = result.spec.item === undefined ? '' : ` item="${escapeXmlAttribute(result.spec.item)}"`; const state = result.state === undefined ? '' : ` state="${result.state}"`; + const stopReason = + result.stopReason === undefined ? '' : ` stop_reason="${escapeXmlAttribute(result.stopReason)}"`; const body = result.status === 'completed' ? (result.result ?? '') : (result.error ?? 'unknown error'); lines.push( - `${body}`, + `${body}`, ); } diff --git a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts index ebf88ac7de4..191ac175309 100644 --- a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts +++ b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts @@ -1112,7 +1112,10 @@ describe('AgentToolDedupeService', () => { return { type: 'function', id, name: 'Bash', arguments: `{"command_${String(variant)}: "ls"` }; } - function rejectedBashAgent(records: TelemetryRecord[]): { + function rejectedBashAgent( + records: TelemetryRecord[], + maxStepsPerTurn?: number, + ): { readonly ctx: ReturnType; readonly exec: ReturnType; } { @@ -1120,6 +1123,7 @@ describe('AgentToolDedupeService', () => { const ctx = createTestAgent( telemetryServices(recordingTelemetry(records)), execEnvServices({ processRunner: createFakeProcessRunner({ spawn: exec as unknown as IHostProcessService['spawn'] }) }), + { initialConfig: { providers: {}, loopControl: { maxStepsPerTurn } } }, ); ctx.get(IAgentProfileService).update({ activeToolNames: ['Bash'] }); records.length = 0; @@ -1187,6 +1191,48 @@ describe('AgentToolDedupeService', () => { ).toHaveLength(0); }); + it('runs the handoff step even when the force stop lands on the step cap', async () => { + const records: TelemetryRecord[] = []; + const { ctx, exec } = rejectedBashAgent(records, 12); + + for (let i = 0; i < 12; i += 1) { + ctx.mockNextResponse(invalidBashCallWithId(`call_bad_${String(i)}`)); + } + ctx.mockNextResponse({ type: 'text', text: 'Handoff: still blocked on the same call.' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Repeat the bad call' }] }); + const turn = (ctx.get(IAgentLoopService) as unknown as { activeTurnJob?: { turn: Turn } }) + .activeTurnJob?.turn; + await ctx.untilTurnEnd(); + + expect(exec).not.toHaveBeenCalled(); + expect(ctx.llmCalls).toHaveLength(13); + await expect(turn!.result).resolves.toMatchObject({ + type: 'completed', + steps: 13, + stopReason: 'repeat_breaker', + }); + }); + + it('still enforces the step cap for ordinary steps', async () => { + const records: TelemetryRecord[] = []; + const { ctx, exec } = rejectedBashAgent(records, 12); + + for (let i = 0; i < 12; i += 1) { + ctx.mockNextResponse(malformedBashCallWithId(`call_mal_${String(i)}`, i)); + } + ctx.mockNextResponse({ type: 'text', text: 'must never be generated' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Repeat the bad call' }] }); + const turn = (ctx.get(IAgentLoopService) as unknown as { activeTurnJob?: { turn: Turn } }) + .activeTurnJob?.turn; + await ctx.untilTurnEnd(); + + expect(exec).not.toHaveBeenCalled(); + expect(ctx.llmCalls).toHaveLength(12); + await expect(turn!.result).resolves.toMatchObject({ type: 'failed', steps: 12 }); + }); + it('does not force-stop when the malformed argument text keeps changing', async () => { const records: TelemetryRecord[] = []; const { ctx, exec } = rejectedBashAgent(records); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 9a34bc322cf..eb4948bd668 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -3444,6 +3444,56 @@ describe('AgentSwarm tool execution contract', () => { expect(result.isError).toBeUndefined(); }); + it('renders a handoff stop reason on a completed subagent and offers a resume hint', async () => { + const runSwarm = vi.fn( + async ( + args: SessionSwarmRunArgs, + ): Promise[]> => [ + { + task: args.tasks[0]!, + agentId: 'agent-coder-1', + status: 'completed' as const, + result: 'imports are stable', + }, + { + task: args.tasks[1]!, + agentId: 'agent-coder-2', + status: 'completed' as const, + result: 'Stuck: the same grep keeps returning nothing.', + stopReason: 'repeat_breaker', + }, + ], + ); + const swarmService: ISessionSwarmService = { + _serviceBrand: undefined, + getSwarmItem: async () => undefined, + run: runSwarm as ISessionSwarmService['run'], + cancel: () => {}, + }; + ctx = createTestAgent(swarmServices(swarmService)); + + const result = await executeTool(agentSwarmTool(ctx), { + turnId: 0, + toolCallId: 'call_swarm', + args: { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + signal, + }); + + expect(result.output).toBe([ + '', + 'completed: 2', + 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', + 'imports are stable', + 'Stuck: the same grep keeps returning nothing.', + '', + ].join('\n')); + expect(result.isError).toBeUndefined(); + }); + it('reports partial aborted subagents inside the XML result', async () => { const runSwarm = vi.fn( async ( From b5e6544720167c6f3a261b1b6cd2026cc865269e Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 2 Sep 2026 16:56:52 +0800 Subject: [PATCH 4/4] fix(agent-core-v2): keep the repeat-breaker classification and persist turn.ended stop reasons - A handoff that ends without text keeps stop_reason repeat_breaker on the parent-facing failure instead of collapsing to no_final_message. - Only a user cancellation maps to stop_reason cancelled; other kills report stopped and keep the resume hint. - turn.ended persists stopReason in its durable record and the wire manifest lists it. --- .../agent-core-v2/docs/wire-manifest.d.ts | 1 + .../agent-core-v2/src/agent/loop/turnOps.ts | 2 + .../src/agent/tools/agent/agentTool.ts | 7 +- .../src/agent/tools/agent/subagent-task.ts | 9 ++- .../test/agent/loop/loop.test.ts | 2 + packages/agent-core-v2/test/tool/tool.test.ts | 65 +++++++++++++++++++ 6 files changed, 83 insertions(+), 3 deletions(-) diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index c4aef5eec49..89e6a8b9656 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -854,6 +854,7 @@ interface TurnEndedPayload { }; }; durationMs?: number; + stopReason?: string; } /** diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts index e4f021c3bc9..5f511599058 100644 --- a/packages/agent-core-v2/src/agent/loop/turnOps.ts +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -91,6 +91,7 @@ const turnEndedSchema = z.object({ reason: z.enum(['completed', 'cancelled', 'failed', 'blocked']), error: z.custom().optional(), durationMs: z.number().optional(), + stopReason: z.string().optional(), }); export interface TurnEndedPayload { @@ -118,6 +119,7 @@ export class TurnEnded extends AgentEvent2 { }; if (this.error !== undefined) record['error'] = this.error; if (this.durationMs !== undefined) record['durationMs'] = this.durationMs; + if (this.stopReason !== undefined) record['stopReason'] = this.stopReason; record['time'] = this.time; return record as SerializedEvent2; } diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index 1a0008831c8..22963cc0e9a 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -526,6 +526,7 @@ type SubagentStopReason = | 'provider_error' | 'no_final_message' | 'cancelled' + | 'stopped' | 'timed_out' | 'error'; @@ -552,11 +553,13 @@ const NEXT_STEP_BY_REASON: Readonly> = { + [REPEAT_BREAKER_STOP_REASON]: 'repeat_breaker', [ErrorCodes.AGENT_MAX_TOKENS_EXCEEDED]: 'max_tokens', [ErrorCodes.LOOP_MAX_STEPS_EXCEEDED]: 'max_steps', [ErrorCodes.PROVIDER_FILTERED]: 'filtered', @@ -577,7 +580,9 @@ function failureStopReason( stopCode: string | undefined, ): SubagentStopReason { if (info?.status === 'timed_out') return 'timed_out'; - if (info?.status === 'killed') return 'cancelled'; + if (info?.status === 'killed') { + return info.stopReason?.trim() === userCancellationReason().message ? 'cancelled' : 'stopped'; + } if (stopCode === undefined) return 'error'; return STOP_REASON_BY_CODE[stopCode] ?? 'error'; } diff --git a/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts b/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts index 667bd7200ac..886a6b0c01e 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts @@ -2,7 +2,7 @@ import type { TokenUsage } from '#/kosong/contract/usage'; import type { SubagentModelSource } from '#/session/subagent/configSection'; import { isAbortError } from '#/_base/utils/abort'; -import { isError2 } from '#/errors'; +import { ErrorCodes, isError2 } from '#/errors'; import { REPEAT_BREAKER_STOP_REASON } from '#/agent/toolDedupe/toolDedupe'; import { type AgentTask, @@ -50,7 +50,12 @@ function errorMessage(err: unknown): string { } function stopCodeOf(error: unknown): string | undefined { - return isError2(error) ? error.code : undefined; + if (!isError2(error)) return undefined; + if (error.code === ErrorCodes.AGENT_NO_FINAL_MESSAGE) { + const stopReason = error.details?.['stopReason']; + if (typeof stopReason === 'string') return stopReason; + } + return error.code; } function completedSettleReason(stopReason: string | undefined): string | undefined { diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 6abd5da7792..4d1848fb3b1 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -669,6 +669,8 @@ describe('Agent loop', () => { (event) => event.type === '[rpc]' && event.event === 'turn.ended', ); expect(turnEnded?.args).toMatchObject({ reason: 'completed', stopReason: 'demo_reason' }); + const record = (await ctx.persistedWireRecords()).find((entry) => entry.type === 'turn.ended'); + expect(record).toMatchObject({ turnId: 0, reason: 'completed', stopReason: 'demo_reason' }); }); it('queues consecutive nextTurn requests in FIFO order without overlapping turns', async () => { diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index eb4948bd668..f85ea918c2d 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -1421,6 +1421,41 @@ describe('Agent tool execution contract', () => { expect(info.stopCode).toBe('repeat_breaker'); }); + it('derives the stop code from the stop reason of a missing-handoff failure', async () => { + const task = new SubagentTask( + { + agentId: 'agent-child', + profileName: 'coder', + completion: Promise.reject( + new Error2(ErrorCodes.AGENT_NO_FINAL_MESSAGE, 'no handoff', { + details: { stopReason: 'repeat_breaker' }, + }), + ), + }, + 'Find cause', + new AbortController(), + ); + const settlements: AgentTaskSettlement[] = []; + await task.start({ + signal: new AbortController().signal, + appendOutput: () => {}, + settle: async (settlement) => { + settlements.push(settlement); + return true; + }, + }); + + expect(settlements).toEqual([{ status: 'failed', stopReason: 'no handoff' }]); + const info = task.toInfo({ + taskId: 'agent-1', + description: 'Find cause', + status: 'failed', + startedAt: 0, + endedAt: 1, + }); + expect(info.stopCode).toBe('repeat_breaker'); + }); + it('reports a missing final message as a failure with stop_reason no_final_message', async () => { const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'], @@ -1448,6 +1483,33 @@ describe('Agent tool execution contract', () => { expect(result.output).toContain('next_step: Resume to continue where it stopped'); }); + it('keeps the repeat_breaker classification when the handoff produced no text', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => { + throw new Error2( + ErrorCodes.AGENT_NO_FINAL_MESSAGE, + 'Subagent turn ended without a final message (stop reason: repeat_breaker).', + { details: { stopReason: 'repeat_breaker' } }, + ); + }, + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('status: failed'); + expect(result.output).toContain('stop_reason: repeat_breaker'); + expect(result.output).toContain('Reason: Subagent turn ended without a final message'); + expect(result.output).toContain('resume_hint: Continue with Agent(resume="agent-child", prompt="continue")'); + expect(result.output).toContain('next_step: The subagent was stuck on one tool call.'); + expect(result.output).not.toContain('[summary]'); + }); + it('maps a step-cap failure to stop_reason max_steps without config advice', async () => { const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'], @@ -2804,9 +2866,12 @@ describe('Agent tool execution contract', () => { const result = await resultPromise; expect(result.isError).toBe(true); + expect(result.output).toContain('stop_reason: stopped'); expect(result.output).toContain( 'The subagent was stopped before it finished. Reason: Session closed', ); + expect(result.output).toContain('resume_hint: Continue with Agent(resume="agent-child"'); + expect(result.output).not.toContain('The user stopped this subagent'); }); it('returns the spawned agent id when a foreground subagent times out', async () => {