-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(agent-core-v2): add a handoff step after forced stops and report subagent stop reasons #3459
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9e19a01
6268262
cf7c5eb
5968066
b5e6544
04d0f05
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -854,6 +854,7 @@ interface TurnEndedPayload { | |
| }; | ||
| }; | ||
| durationMs?: number; | ||
| stopReason?: string; | ||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,6 +56,7 @@ import { | |
| type TurnSeed, | ||
| } from './stepRequest'; | ||
| import { StepRequestQueue, type StepRequestBatch } from './stepRequestQueue'; | ||
| import { HANDOFF_STEP_KIND } from './handoffStep'; | ||
| import { | ||
| AssistantDelta, | ||
| isDisplayablePromptOrigin, | ||
|
|
@@ -537,6 +538,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { | |
| error, | ||
| durationMs, | ||
| interruptReason, | ||
| stopReason: result.type === 'completed' ? result.stopReason : undefined, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the main agent has an active goal and the repeat breaker completes its handoff, this emits Useful? React with 👍 / 👎. |
||
| }), | ||
| ); | ||
| if (error !== undefined) { | ||
|
|
@@ -675,24 +677,37 @@ 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<LoopControl>(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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new exhausted-cap exemption trusts only the request's public string Useful? React with 👍 / 👎. |
||
| ) { | ||
| throw createMaxStepsExceededError(maxSteps); | ||
| } | ||
| const batch = runtime.queue.takeNextBatch()!; | ||
|
|
@@ -727,14 +742,17 @@ 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', | ||
| details: { finishReason: 'filtered' }, | ||
| }); | ||
| } | ||
| if (!result.hookStopTurn) return undefined; | ||
| return { type: 'completed', steps: runtime.steps, truncated: result.stopReason === 'truncated' }; | ||
| return this.completedResult(runtime); | ||
| } | ||
|
|
||
| private async handleLoopStepError( | ||
|
|
@@ -861,7 +879,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 +897,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 +986,14 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { | |
| stepUuid: string, | ||
| response: AgentLLMRequestFinish, | ||
| trace: LLMRequestTrace, | ||
| ): Promise<FinishReason> { | ||
| ): Promise<StepToolsOutcome> { | ||
| 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<string, string>(); | ||
| let stopTurn = false; | ||
| let stopTurnReason: string | undefined; | ||
| for await (const toolResult of this.toolExecutor.execute(response.message.toolCalls, { | ||
| signal, | ||
| turnId, | ||
|
|
@@ -1003,10 +1022,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 +1261,7 @@ interface LoopRuntime { | |
| readonly queue: StepRequestQueue; | ||
| steps: number; | ||
| lastStopReason: FinishReason | undefined; | ||
| forcedStopReason: string | undefined; | ||
| current: StepRuntime | undefined; | ||
| } | ||
|
|
||
|
|
@@ -1277,6 +1300,12 @@ function interruptReasonFor( | |
| type StepExecutionResult = { | ||
| readonly stopReason: FinishReason; | ||
| readonly hookStopTurn: boolean; | ||
| readonly stopTurnReason?: string; | ||
| }; | ||
|
|
||
| type StepToolsOutcome = { | ||
| readonly finishReason: FinishReason; | ||
| readonly stopTurnReason?: string; | ||
| }; | ||
|
|
||
| type LoopErrorDisposition = | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This changeset covers only removal of the short-summary retry, while the same commit also makes the CLI run a new repeat-breaker handoff step and expose subagent stop reasons in Agent and AgentSwarm output. Those are separate user-visible behavior changes, so without a second changeset they will be omitted from the generated release notes.
AGENTS.md reference: AGENTS.md:L85-L87
Useful? React with 👍 / 👎.