Skip to content

Commit 5385ec3

Browse files
roblourensCopilot
andcommitted
agentHost: don't subscribe to subagent chats that never spawn
A `task` tool call that fails without spawning a subagent (for example when the SDK rejects it with "Maximum sub-agent depth of 4 reached") left the workbench observing a child chat URI the host never created. The host waited out its pending-subagent window and then failed the subscription: [ProtocolServer] Request 'subscribe' failed Resource not found: ahp-chat://subagent/<session>/<toolCallId> `AgentHostSessionHandler` now observes a subagent chat only while the tool can still produce one, and releases the child-chat subscription when the tool completes unsuccessfully without a subagent content block. Per-observation disposables move into a `DisposableMap` keyed by tool call so a released observation also tears down its autoruns and subscriptions. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 69afa90 commit 5385ec3

4 files changed

Lines changed: 91 additions & 12 deletions

File tree

src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ import { buildHostLocalEventsPath } from '../../copilotCliEventsUri.js';
104104
import { toolDataToDefinition } from './agentHostToolUtils.js';
105105
import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js';
106106
import { IAgentHostImportConversationStore } from './agentHostImportConversationStore.js';
107-
import { activeTurnToProgress, BOOLEAN_TRUE_OPTION_ID, completedToolCallToEditParts, completedToolCallToSerialized, containsAutomaticReplyAnswer, convertProtocolAnswers, convertProtocolPlanReviewResult, createInputRequestCarousel, createInputRequestPlanReview, finalizeToolInvocation, formatTurnResponseDetails, getTerminalContent, getUrlInputRequestPresentation, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToVariableData, parseAhpTerminalToolSessionId, rewriteAgentHostLinkTarget, stringOrMarkdownToString, systemNotificationToChatPart, toolCallAuthenticationServer, toolCallStateToInvocation, toolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, turnsToHistory, updateRunningToolSpecificData, updateStreamingToolInvocation, usageInfoToAutoModeResolution, usageInfoToChatUsage, usageInfoToQuotas, type IAgentHostToolInvocationOptions, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js';
107+
import { activeTurnToProgress, BOOLEAN_TRUE_OPTION_ID, completedToolCallToEditParts, completedToolCallToSerialized, containsAutomaticReplyAnswer, convertProtocolAnswers, convertProtocolPlanReviewResult, createInputRequestCarousel, createInputRequestPlanReview, finalizeToolInvocation, formatTurnResponseDetails, getTerminalContent, getUrlInputRequestPresentation, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToVariableData, parseAhpTerminalToolSessionId, rewriteAgentHostLinkTarget, shouldObserveSubagentChat, stringOrMarkdownToString, systemNotificationToChatPart, toolCallAuthenticationServer, toolCallStateToInvocation, toolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, turnsToHistory, updateRunningToolSpecificData, updateStreamingToolInvocation, usageInfoToAutoModeResolution, usageInfoToChatUsage, usageInfoToQuotas, type IAgentHostToolInvocationOptions, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js';
108108
import { resolveMcpServerAuthentication, agentHostMcpServerId, modelRequiresAgentAuthentication } from './agentHostAuth.js';
109109
export { toolDataToDefinition };
110110

@@ -231,8 +231,8 @@ interface IObserveTurnOptions {
231231
* subagent tool calls already have observers so they aren't double-subscribed.
232232
*/
233233
interface ISubagentContext {
234-
/** Tool call IDs already subscribed — prevents duplicate observers. */
235-
readonly observedToolIds: Set<string>;
234+
/** Active child-chat observers keyed by their spawning tool call. */
235+
readonly observations: DisposableMap<string>;
236236
}
237237

238238
interface IOutputTerminalAttachment {
@@ -2967,7 +2967,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
29672967
// Subagent observation context: dedups subagent tool calls so each is
29682968
// observed once.
29692969
const subagentContext: ISubagentContext = {
2970-
observedToolIds: new Set<string>(),
2970+
observations: store.add(new DisposableMap()),
29712971
};
29722972

29732973
// Per response part. Markdown / reasoning / tool calls each get a
@@ -3774,7 +3774,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
37743774
return;
37753775
}
37763776

3777-
const isObserved = subagentContext.observedToolIds.has(toolCallId);
3777+
const isObserved = subagentContext.observations.has(toolCallId);
37783778
const currentData = invocation.toolSpecificData?.kind === 'subagent' ? invocation.toolSpecificData : undefined;
37793779
const prepared = toolCallStateToPreparedInvocation(toolCall, opts.backendSession, this._config.connectionAuthority, opts.sessionResource.authority);
37803780
const protocolData = prepared.toolSpecificData?.kind === 'subagent' ? prepared.toolSpecificData : undefined;
@@ -3799,23 +3799,28 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
37993799
invocation.notifyToolSpecificDataChanged();
38003800
}
38013801

3802+
if (isObserved && !shouldObserveSubagentChat(toolCall)) {
3803+
subagentContext.observations.deleteAndDispose(toolCallId);
3804+
return;
3805+
}
38023806
if (isObserved) {
38033807
return;
38043808
}
3805-
if (toolCall.status !== ToolCallStatus.Running && toolCall.status !== ToolCallStatus.Completed) {
3809+
if (!shouldObserveSubagentChat(toolCall)) {
38063810
return;
38073811
}
38083812

38093813
const subagentData = invocation.toolSpecificData;
38103814
if (subagentData?.kind !== 'subagent') {
38113815
return;
38123816
}
3813-
subagentContext.observedToolIds.add(toolCallId);
3817+
const observationStore = new DisposableStore();
3818+
subagentContext.observations.set(toolCallId, observationStore);
38143819
subagentData.isActive = true;
38153820
invocation.notifyToolSpecificDataChanged();
38163821

38173822
const perInvocationCredits = observableValue<number>('subagentInvocationCredits', 0);
3818-
store.add(autorun(reader => {
3823+
observationStore.add(autorun(reader => {
38193824
const total = perInvocationCredits.read(reader);
38203825
if (total > 0 && invocation.toolSpecificData?.kind === 'subagent' && invocation.toolSpecificData.credits !== total) {
38213826
invocation.toolSpecificData.credits = total;
@@ -3824,7 +3829,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
38243829
}));
38253830

38263831
const perInvocationModel = observableValue<string | undefined>('subagentInvocationModel', undefined);
3827-
store.add(autorun(reader => {
3832+
observationStore.add(autorun(reader => {
38283833
const modelName = perInvocationModel.read(reader);
38293834
if (modelName && invocation.toolSpecificData?.kind === 'subagent' && invocation.toolSpecificData.modelName !== modelName) {
38303835
invocation.toolSpecificData.modelName = modelName;
@@ -3835,7 +3840,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
38353840
const rootInvocationId = opts.subAgentInvocationId ?? toolCallId;
38363841
const childChatUri = subagentData.chatResource
38373842
|| buildSubagentChatUri(opts.backendSession.toString(), toolCallId);
3838-
this._observeSubagentSession(opts.sessionResource, opts.backendSession, toolCallId, childChatUri, rootInvocationId, invocation, opts.sink, store, subagentContext, perInvocationCredits, perInvocationModel);
3843+
this._observeSubagentSession(opts.sessionResource, opts.backendSession, toolCallId, childChatUri, rootInvocationId, invocation, opts.sink, observationStore, subagentContext, perInvocationCredits, perInvocationModel);
38393844
}
38403845

38413846
/**
@@ -4692,7 +4697,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
46924697
));
46934698
} catch (err) {
46944699
// Remove from observed set so a later state change can retry
4695-
subagentContext.observedToolIds.delete(parentToolCallId);
4700+
subagentContext.observations.deleteAndDispose(parentToolCallId);
46964701
this._logService.warn(`[AgentHost] Failed to subscribe to subagent chat: ${childChatUri}`, err);
46974702
}
46984703
}

src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,17 @@ export function isSubagentTool(tc: ToolCallState): boolean {
461461
return getToolKind(tc) === 'subagent' || isSubagentToolName(tc.toolName);
462462
}
463463

464+
/** Returns whether the tool call can have a child chat worth observing. */
465+
export function shouldObserveSubagentChat(tc: ToolCallState): boolean {
466+
const hasSubagentContent = (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed)
467+
&& getToolSubagentContent(tc) !== undefined;
468+
if (tc.status === ToolCallStatus.Running) {
469+
return isSubagentTool(tc) || hasSubagentContent;
470+
}
471+
return tc.status === ToolCallStatus.Completed
472+
&& (hasSubagentContent || (tc.success && isSubagentTool(tc)));
473+
}
474+
464475
/**
465476
* Finds a terminal content block in a tool call's content array.
466477
* Returns the terminal URI if found.

src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,11 @@ class MockAgentHostService extends mock<IAgentHostService>() {
270270
return this.dispatchedActions.filter(d => d.action.type === 'chat/turnStarted');
271271
}
272272
public sessionStates = new Map<string, SeededSessionState>();
273+
274+
hasLiveSubscription(resource: string): boolean {
275+
return this._liveSubscriptions.has(resource);
276+
}
277+
273278
async subscribe(resource: URI): Promise<IStateSnapshot> {
274279
const resourceStr = resource.toString();
275280
const existingState = this.sessionStates.get(resourceStr);
@@ -11096,6 +11101,42 @@ suite('AgentHostChatContribution', () => {
1109611101
await turnPromise;
1109711102
}));
1109811103

11104+
test('failed subagent tool calls release child chat subscriptions', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
11105+
const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables);
11106+
const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables);
11107+
const parentSession = parseDefaultChatUri(session);
11108+
assert.ok(parentSession);
11109+
const toolCallId = 'tc-failed-task';
11110+
const childChatUri = buildSubagentChatUri(parentSession, toolCallId);
11111+
11112+
fire({
11113+
type: 'chat/toolCallStart', session, turnId,
11114+
toolCallId, toolName: 'task', displayName: 'Delegate Task',
11115+
_meta: { toolKind: 'subagent', subagentChatUri: childChatUri },
11116+
} as ChatAction);
11117+
fire({
11118+
type: 'chat/toolCallReady', session, turnId,
11119+
toolCallId, invocationMessage: 'Delegating task',
11120+
confirmed: ToolCallConfirmationReason.NotNeeded,
11121+
} as ChatAction);
11122+
await timeout(0);
11123+
assert.strictEqual(agentHostService.hasLiveSubscription(childChatUri), true);
11124+
11125+
fire({
11126+
type: 'chat/toolCallComplete', session, turnId, toolCallId,
11127+
result: {
11128+
success: false,
11129+
pastTenseMessage: '"Delegate Task" failed',
11130+
error: { message: 'Maximum sub-agent depth reached', code: 'failure' },
11131+
},
11132+
} as ChatAction);
11133+
await timeout(0);
11134+
assert.strictEqual(agentHostService.hasLiveSubscription(childChatUri), false);
11135+
11136+
fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction);
11137+
await turnPromise;
11138+
}));
11139+
1109911140
test('inner subagent tool calls fired AFTER parent observation are also grouped', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
1110011141
const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables);
1110111142

src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { buildSubagentChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, C
1818
import { ChatTranscriptContextAttachmentDisplayKind, IChatRequestTranscriptContextVariableEntry, toChatTranscriptContextAttachmentMeta } from '../../../common/attachments/chatVariableEntries.js';
1919
import { IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatMarkdownContent, type IChatTerminalToolInvocationData, type IChatThinkingPart, type IChatUsage } from '../../../common/chatService/chatService.js';
2020
import { isToolResultInputOutputDetails, type IToolResultInputOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js';
21-
import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, completedToolCallToSerialized, containsAutomaticReplyAnswer, createInputRequestCarousel, messageAttachmentsToVariableData, toolCallStateToInvocation as rawToolCallStateToInvocation, toolCallStateToPreparedInvocation as rawToolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData, updateStreamingToolInvocation, usageInfoToAutoModeResolution, usageInfoToChatUsage, usageInfoToQuotas, formatTurnResponseDetails, rewriteAgentHostLinkTarget, rewriteMarkdownLinks, type TurnModelLookup } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js';
21+
import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, completedToolCallToSerialized, containsAutomaticReplyAnswer, createInputRequestCarousel, messageAttachmentsToVariableData, shouldObserveSubagentChat, toolCallStateToInvocation as rawToolCallStateToInvocation, toolCallStateToPreparedInvocation as rawToolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData, updateStreamingToolInvocation, usageInfoToAutoModeResolution, usageInfoToChatUsage, usageInfoToQuotas, formatTurnResponseDetails, rewriteAgentHostLinkTarget, rewriteMarkdownLinks, type TurnModelLookup } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js';
2222

2323
// ---- Helper factories -------------------------------------------------------
2424

@@ -1408,6 +1408,28 @@ suite('stateToProgressAdapter', () => {
14081408
}
14091409
});
14101410

1411+
test('observes only failed subagent tools that produced a child chat', () => {
1412+
const subagentContent: ToolResultContent = {
1413+
type: ToolResultContentType.Subagent,
1414+
resource: 'ahp-chat://subagent/session/tc-1',
1415+
title: 'Explore',
1416+
agentName: 'explore',
1417+
description: 'Explores the codebase',
1418+
};
1419+
1420+
assert.deepStrictEqual({
1421+
running: shouldObserveSubagentChat(createToolCallState({ toolName: 'task' })),
1422+
completed: shouldObserveSubagentChat(createCompletedToolCall({ toolName: 'task' })),
1423+
failedWithoutChild: shouldObserveSubagentChat(createCompletedToolCall({ toolName: 'task', success: false })),
1424+
failedWithChild: shouldObserveSubagentChat(createCompletedToolCall({ toolName: 'task', success: false, content: [subagentContent] })),
1425+
}, {
1426+
running: true,
1427+
completed: true,
1428+
failedWithoutChild: false,
1429+
failedWithChild: true,
1430+
});
1431+
});
1432+
14111433
test('prefers the host-stamped _meta.subagentChatUri over a discovery content block resource', () => {
14121434
const tc = createToolCallState({
14131435
_meta: { toolKind: 'subagent', subagentChatUri: 'ahp-chat://subagent/stamped/tc-1' },

0 commit comments

Comments
 (0)