Skip to content

Execution Subagent Async Behaviour#313067

Open
vikramnitin9 wants to merge 2 commits intomicrosoft:mainfrom
vikramnitin9:vikram/exec_subagent_async
Open

Execution Subagent Async Behaviour#313067
vikramnitin9 wants to merge 2 commits intomicrosoft:mainfrom
vikramnitin9:vikram/exec_subagent_async

Conversation

@vikramnitin9
Copy link
Copy Markdown
Member

The execution subagent doesn't work well with async terminals (see #308048). The system prompt asks it to refrain from calling run_in_terminal async, 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_output and kill_terminal so that it could monitor background terminals. Drawbacks:

  1. The subagent call itself is not async, so the main agent remains blocked while waiting for the subagent to finish. Given that, it is undesirable to give the subagent the ability to run commands with unbounded time horizons.
  2. The main agent can relinquish control of long-running commands and get a ping when they complete. The subagent cannot.
  3. The subagent is being developed as an extremely lightweight custom model. It struggles to handle an expanded toolset.

The proposed solution is:

  1. Automatically detect when a terminal call times out
  2. Prompt the subagent to finish and return the <final_answer>
  3. Append the command(s) that timed out along with its terminal ID, to the subagent's response that is sent back to the main agent. This way, the main agent can take further action using 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 | .... or yes | .... This circumvents the need for an additional tool send_output_to_terminal.

Tagging @roblourens and @meganrogge for review.

@vikramnitin9 vikramnitin9 force-pushed the vikram/exec_subagent_async branch from 7ef9a55 to c000a0a Compare April 28, 2026 18:00
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_terminal tool results and plumb timed-out command details back through the execution subagent tool result.
  • Restrict the execution subagent toolset to run_in_terminal only 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 useAgenticProxy is enabled, getEndpoint() creates a new ProxyAgenticEndpoint instance every time it’s called. Since getEndpoint() is invoked from buildPrompt, getAvailableTools, and fetch, 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

Comment on lines 134 to 138
{
promptContext: buildpromptContext,
maxExecutionTurns
maxExecutionTurns,
hasTimedOutCommand: this._timedOutCommands.length > 0,
}
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
		}

Copilot uses AI. Check for mistakes.
Comment on lines +144 to +147
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');
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
* 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 {
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 {

Copilot uses AI. Check for mistakes.
@meganrogge
Copy link
Copy Markdown
Collaborator

getTerminalTimeoutInfo returns { termId: '' } when m.id is missing or not a string (line 206), instead of returning undefined to skip the entry. This means a timed-out command with corrupt/missing metadata gets surfaced to the main agent with an empty terminal ID — the main agent can't do anything useful with get_terminal_output or kill_terminal on "". Should return undefined to skip it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants