Execution Subagent Async Behaviour#313067
Execution Subagent Async Behaviour#313067vikramnitin9 wants to merge 2 commits intomicrosoft:mainfrom
Conversation
7ef9a55 to
c000a0a
Compare
There was a problem hiding this comment.
Pull request overview
This PR updates the Copilot execution subagent flow to better handle terminals that “go async” after a sync timeout by (a) detecting timed-out run_in_terminal calls, (b) nudging the subagent to stop and return <final_answer>, and (c) surfacing timed-out command + terminal ID back to the main agent for follow-up actions.
Changes:
- Add timeout detection for
run_in_terminaltool results and plumb timed-out command details back through the execution subagent tool result. - Restrict the execution subagent toolset to
run_in_terminalonly and adjust the execution subagent system prompt accordingly. - Add an experiment-based setting to run the execution subagent against an agentic proxy endpoint (with a default router model).
Show a summary per file
| File | Description |
|---|---|
| extensions/copilot/src/platform/endpoint/node/proxyAgenticEndpoint.ts | Renames/export the generic agentic proxy endpoint class for broader reuse. |
| extensions/copilot/src/platform/configuration/common/configurationService.ts | Adds ExecutionSubagentUseAgenticProxy config and clarifies model defaulting behavior. |
| extensions/copilot/src/extension/tools/node/executionSubagentTool.ts | Appends “timed out command” notes after </final_answer> in the subagent response. |
| extensions/copilot/src/extension/prompts/node/agent/executionSubagentPrompt.tsx | Updates execution subagent instructions and adds a “stop now” nudge trigger. |
| extensions/copilot/src/extension/prompts/node/agent/defaultAgentInstructions.tsx | Avoids referencing the execution subagent tool when it’s not available. |
| extensions/copilot/src/extension/prompts/node/agent/anthropicPrompts.tsx | Same conditional instruction update for the Anthropic prompt variant (plus import ordering). |
| extensions/copilot/src/extension/prompt/node/searchSubagentToolCallingLoop.ts | Switches to the renamed ProxyAgenticEndpoint class. |
| extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts | Adds agentic-proxy endpoint option, timeout collection, and removes extra terminal tools. |
| extensions/copilot/package.nls.json | Adds localized setting description for the new execution-subagent proxy toggle. |
| extensions/copilot/package.json | Registers the new github.copilot.chat.executionSubagent.useAgenticProxy setting. |
Copilot's findings
Comments suppressed due to low confidence (2)
extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts:102
- When
useAgenticProxyis enabled,getEndpoint()creates a newProxyAgenticEndpointinstance every time it’s called. SincegetEndpoint()is invoked frombuildPrompt,getAvailableTools, andfetch, this can lead to repeated endpoint construction per iteration. Consider memoizing the resolved endpoint for the lifetime of the loop (or at least while the config/modelName are unchanged).
if (useAgenticProxy) {
// Use agentic proxy with ExecutionSubagentModel or default to DEFAULT_AGENTIC_PROXY_MODEL
const agenticProxyModel = modelName || ExecutionSubagentToolCallingLoop.DEFAULT_AGENTIC_PROXY_MODEL;
return this.instantiationService.createInstance(ProxyAgenticEndpoint, agenticProxyModel);
}
extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts:115
- The fallback paths here silently switch to the main agent endpoint when the configured model doesn’t support tool calls or can’t be resolved. For diagnosability (and consistency with SearchSubagentToolCallingLoop), consider logging a warning that includes the configured model name and the reason for fallback.
// Model does not support tool calls, fallback to main agent endpoint
return await this.endpointProvider.getChatEndpoint(this.options.request);
} catch (error) {
// Model not available, fallback to main agent endpoint
return await this.endpointProvider.getChatEndpoint(this.options.request);
- Files reviewed: 3/3 changed files
- Comments generated: 3
| { | ||
| promptContext: buildpromptContext, | ||
| maxExecutionTurns | ||
| maxExecutionTurns, | ||
| hasTimedOutCommand: this._timedOutCommands.length > 0, | ||
| } |
There was a problem hiding this comment.
hasTimedOutCommand is computed before collectTimedOutCommands() runs, but tool execution (and thus timeout detection) happens during prompt building. This means the first timed-out run_in_terminal call won’t trigger the “show <final_answer>” nudge in the same request, and the subagent may keep issuing more tool calls. Consider updating the built prompt after rendering (e.g., inject a high-priority user message / re-render) when a new timeout is detected so the model is instructed to stop immediately.
See below for a potential fix:
const hadTimedOutCommand = this._timedOutCommands.length > 0;
// If the previous render observed any timed-out terminal commands, tell the
// prompt to nudge the model to stop issuing tool calls and produce its
// <final_answer>. The natural "no tool calls" exit then ends the loop.
let renderer = PromptRenderer.create(
this.instantiationService,
endpoint,
ExecutionSubagentPrompt,
{
promptContext: buildpromptContext,
maxExecutionTurns,
hasTimedOutCommand: hadTimedOutCommand,
}
);
let result = await renderer.render(progress, token);
// After rendering, scan the rendered tool results for timeouts. Every tool
// call rendered into the prompt (including those executed just now during
// this render) emits a ToolResultMetadata entry on `result.metadata`.
this.collectTimedOutCommands(buildpromptContext, result);
// A timed-out command can be discovered while rendering the prompt. If that
// happens, immediately rebuild the prompt so the model gets the high-priority
// instruction to stop making tool calls and provide its <final_answer>.
if (!hadTimedOutCommand && this._timedOutCommands.length > 0) {
renderer = PromptRenderer.create(
this.instantiationService,
endpoint,
ExecutionSubagentPrompt,
{
promptContext: buildpromptContext,
maxExecutionTurns,
hasTimedOutCommand: true,
}
);
result = await renderer.render(progress, token);
this.collectTimedOutCommands(buildpromptContext, result);
}
| const notes = timedOutCommands.map(c => { | ||
| const timeoutText = c.timeoutMs !== undefined ? ` after ${c.timeoutMs} ms` : ''; | ||
| return `Note: The command \`${c.command}\` timed out${timeoutText}. It may still be running in terminal ID ${c.termId}.`; | ||
| }).join('\n'); |
There was a problem hiding this comment.
The command is interpolated into a Markdown inline code span using backticks. If the command contains backticks or newlines, the note formatting can break and may confuse downstream parsing/reading. Consider escaping/backtick-doubling or using a safer representation (e.g., quoted/JSON-escaped string) for the command text in the note.
| * immediately after the final `</final_answer>` of the subagent's response. If | ||
| * no `<final_answer>` block is present, appends the notes to the end of the response. | ||
| */ | ||
| function appendTimeoutNotesToFinalAnswer(response: string, timedOutCommands: ReadonlyArray<{ command: string; termId: string; timeoutMs?: number }>): string { |
There was a problem hiding this comment.
New string-manipulation logic in appendTimeoutNotesToFinalAnswer() isn’t covered by tests. Since there’s already an ExecutionSubagentTool test suite, consider adding unit tests for: (1) response with a final_answer block, (2) multiple final_answer blocks (lastIndexOf behavior), and (3) no final_answer block.
| function appendTimeoutNotesToFinalAnswer(response: string, timedOutCommands: ReadonlyArray<{ command: string; termId: string; timeoutMs?: number }>): string { | |
| export function appendTimeoutNotesToFinalAnswer(response: string, timedOutCommands: ReadonlyArray<{ command: string; termId: string; timeoutMs?: number }>): string { |
|
|
The execution subagent doesn't work well with async terminals (see #308048). The system prompt asks it to refrain from calling
run_in_terminalasync, but it is still possible for a sync terminal call to become async once the timeout expires.The old workaround was to give the subagent access to tools like
get_terminal_outputandkill_terminalso that it could monitor background terminals. Drawbacks:The proposed solution is:
<final_answer>get_terminal_output,kill_terminal, etc.Analysis of telemetry shows that only 10% of all subagent trajectories contain at least one terminal call that times out, so it is acceptable for the subagent to hand back control to the main agent when it encounters such a scenario.
Another minority scenario (~1.1% of all subagent trajectories, from telemetry) is when a command waits on user input. To account for such cases, the subagent's system prompt instructs it to re-run the command with a pipe
echo y | ....oryes | .... This circumvents the need for an additional toolsend_output_to_terminal.Tagging @roblourens and @meganrogge for review.