Skip to content

Commit a4f5119

Browse files
roblourensCopilot
andauthored
agentHost: subagents (#308592)
* agentHost: subagents * agentHost: remove _meta.parentToolCallId dependency, subscribe to child sessions instead Inner tool calls from subagent sessions are no longer stored in the parent turn with _meta.parentToolCallId. Instead: - Server: _buildTurnsFromMessages skips inner events (parentToolCallId), _restoreSubagentSession builds child session turns from raw messages - Client: _enrichHistoryWithSubagentCalls subscribes to child sessions during history restore, injects serialized inner tool calls with subAgentInvocationId set Also fixes hygiene: replace 'in' operator with hasKey in agentSideEffects.test.ts, exclude .jsonl from copyright filter. * fix: set terminalCommandUri from terminal content blocks in stateToProgressAdapter completedToolCallToSerialized and toolCallStateToInvocation were not detecting terminal tools via ToolResultContentType.Terminal content blocks or setting terminalCommandUri/terminalToolSessionId, causing 6 test failures in CI. * comments Co-authored-by: Copilot <copilot@github.com> * revert diff --------- Co-authored-by: Copilot <copilot@github.com>
1 parent dcacca1 commit a4f5119

24 files changed

+1802
-75
lines changed

build/filters.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ export const copyrightFilter = Object.freeze<string[]>([
162162
'**',
163163
'!**/*.desktop',
164164
'!**/*.json',
165+
'!**/*.jsonl',
165166
'!**/*.html',
166167
'!**/*.template',
167168
'!**/*.md',

src/vs/platform/agentHost/common/agentService.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,8 @@ export interface IAgentToolStartEvent extends IAgentProgressEventBase {
169169
readonly invocationMessage: string;
170170
/** A representative input string for display in the UI (e.g., the shell command). */
171171
readonly toolInput?: string;
172-
/** Hint for the renderer about how to display this tool (e.g., 'terminal' for shell commands). */
173-
readonly toolKind?: 'terminal';
172+
/** Hint for the renderer about how to display this tool (e.g., 'terminal' for shell commands, 'subagent' for subagent-spawning tools). */
173+
readonly toolKind?: 'terminal' | 'subagent';
174174
/** Language identifier for syntax highlighting (e.g., 'shellscript', 'powershell'). Used with toolKind 'terminal'. */
175175
readonly language?: string;
176176
/** Serialized JSON of the tool arguments, if available. */
@@ -252,6 +252,15 @@ export interface IAgentUserInputRequestEvent extends IAgentProgressEventBase {
252252
readonly request: ISessionInputRequest;
253253
}
254254

255+
/** A subagent has been spawned by a tool call. */
256+
export interface IAgentSubagentStartedEvent extends IAgentProgressEventBase {
257+
readonly type: 'subagent_started';
258+
readonly toolCallId: string;
259+
readonly agentName: string;
260+
readonly agentDisplayName: string;
261+
readonly agentDescription?: string;
262+
}
263+
255264
export type IAgentProgressEvent =
256265
| IAgentDeltaEvent
257266
| IAgentMessageEvent
@@ -264,7 +273,8 @@ export type IAgentProgressEvent =
264273
| IAgentUsageEvent
265274
| IAgentReasoningEvent
266275
| IAgentSteeringConsumedEvent
267-
| IAgentUserInputRequestEvent;
276+
| IAgentUserInputRequestEvent
277+
| IAgentSubagentStartedEvent;
268278

269279
// ---- Session URI helpers ----------------------------------------------------
270280

@@ -328,7 +338,7 @@ export interface IAgent {
328338
setPendingMessages?(session: URI, steeringMessage: IPendingMessage | undefined, queuedMessages: readonly IPendingMessage[]): void;
329339

330340
/** Retrieve all session events/messages for reconstruction. */
331-
getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]>;
341+
getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[]>;
332342

333343
/** Dispose a session, freeing resources. */
334344
disposeSession(session: URI): Promise<void>;

src/vs/platform/agentHost/common/state/sessionReducers.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,22 @@
88

99
// Re-export reducers from the protocol layer
1010
export { rootReducer, sessionReducer, softAssertNever, isClientDispatchable } from './protocol/reducers.js';
11+
12+
import type { ICompletedToolCall, IToolCallState } from './sessionState.js';
13+
14+
/**
15+
* Extracts the VS Code-specific `toolKind` hint from a tool call's `_meta`
16+
* bag. This is not part of the protocol and is injected by the agent adapter
17+
* (e.g. `copilotEventMapper`).
18+
*/
19+
export function getToolKind(tc: IToolCallState | ICompletedToolCall): 'terminal' | 'subagent' | undefined {
20+
return tc._meta?.toolKind as 'terminal' | 'subagent' | undefined;
21+
}
22+
23+
/**
24+
* Extracts the VS Code-specific `language` hint from a tool call's `_meta`
25+
* bag. Used for syntax-highlighting terminal tool output.
26+
*/
27+
export function getToolLanguage(tc: IToolCallState | ICompletedToolCall): string | undefined {
28+
return tc._meta?.language as string | undefined;
29+
}

src/vs/platform/agentHost/common/state/sessionState.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
type IToolCallCompletedState,
2424
type IToolCallResult,
2525
type IToolCallState,
26+
type IToolResultContent,
27+
type IToolResultSubagentContent,
2628
type IToolResultTextContent,
2729
type IUserMessage,
2830
ITerminalState,
@@ -62,6 +64,7 @@ export {
6264
type IToolResultEmbeddedResourceContent as IToolResultBinaryContent,
6365
type IToolResultContent,
6466
type IToolResultFileEditContent,
67+
type IToolResultSubagentContent,
6568
type IToolResultTextContent,
6669
type ITurn,
6770
type IUsageInfo,
@@ -166,6 +169,61 @@ export function getToolFileEdits(result: IToolCallResult): IToolResultFileEditCo
166169
return edits;
167170
}
168171

172+
/**
173+
* Extracts the first subagent content entry from a tool call's `content` array.
174+
* Works with both completed tool call results and running tool call states.
175+
* Returns `undefined` if there are no subagent content parts.
176+
*/
177+
export function getToolSubagentContent(result: { content?: readonly IToolResultContent[] }): IToolResultSubagentContent | undefined {
178+
if (!result.content || result.content.length === 0) {
179+
return undefined;
180+
}
181+
for (const c of result.content) {
182+
if (hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent) {
183+
return c as IToolResultSubagentContent;
184+
}
185+
}
186+
return undefined;
187+
}
188+
189+
// ---- Subagent URI helpers ---------------------------------------------------
190+
191+
/**
192+
* Builds a subagent session URI from a parent session URI and tool call ID.
193+
* Convention: `{parentSessionUri}/subagent/{toolCallId}`
194+
*/
195+
export function buildSubagentSessionUri(parentSession: string, toolCallId: string): string {
196+
// Normalize: strip trailing slash from parent to avoid double-slash in URI
197+
const parent = parentSession.endsWith('/') ? parentSession.slice(0, -1) : parentSession;
198+
return `${parent}/subagent/${toolCallId}`;
199+
}
200+
201+
/**
202+
* Parses a subagent session URI into its parent session URI and tool call ID.
203+
* Returns `undefined` if the URI does not follow the subagent convention.
204+
*/
205+
export function parseSubagentSessionUri(uri: string): { parentSession: string; toolCallId: string } | undefined {
206+
const idx = uri.lastIndexOf('/subagent/');
207+
if (idx < 0) {
208+
return undefined;
209+
}
210+
const toolCallId = uri.substring(idx + '/subagent/'.length);
211+
if (!toolCallId) {
212+
return undefined;
213+
}
214+
return {
215+
parentSession: uri.substring(0, idx),
216+
toolCallId,
217+
};
218+
}
219+
220+
/**
221+
* Returns whether a session URI represents a subagent session.
222+
*/
223+
export function isSubagentSession(uri: string): boolean {
224+
return uri.includes('/subagent/');
225+
}
226+
169227
// ---- Factory helpers --------------------------------------------------------
170228

171229
export function createRootState(): IRootState {

src/vs/platform/agentHost/node/agentEventMapper.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,30 @@ export class AgentEventMapper {
9292
// We emit both toolCallStart (streaming → created) and toolCallReady
9393
// (params complete → running with auto-confirm) as a pair.
9494
const e = event as IAgentToolStartEvent;
95+
const meta: Record<string, unknown> = { toolKind: e.toolKind, language: e.language };
96+
97+
// For subagent tools, extract agent metadata from tool arguments
98+
// so the renderer can display the name/description immediately.
99+
if (e.toolKind === 'subagent' && e.toolArguments) {
100+
try {
101+
const args = JSON.parse(e.toolArguments) as Record<string, unknown>;
102+
if (typeof args.description === 'string') {
103+
meta.subagentDescription = args.description;
104+
}
105+
if (typeof args.agentName === 'string') {
106+
meta.subagentAgentName = args.agentName;
107+
}
108+
} catch { /* ignore parse errors */ }
109+
}
110+
95111
const startAction: IToolCallStartAction = {
96112
type: ActionType.SessionToolCallStart,
97113
session,
98114
turnId,
99115
toolCallId: e.toolCallId,
100116
toolName: e.toolName,
101117
displayName: e.displayName,
102-
_meta: { toolKind: e.toolKind, language: e.language },
118+
_meta: meta,
103119
};
104120
const readyAction: IToolCallReadyAction = {
105121
type: ActionType.SessionToolCallReady,

src/vs/platform/agentHost/node/agentHostStateManager.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,20 @@ export class AgentHostStateManager extends Disposable {
6767
return this._serverSeq;
6868
}
6969

70+
/**
71+
* Returns all session URIs whose keys start with the given prefix.
72+
* Used to discover subagent sessions for a given parent.
73+
*/
74+
getSessionUrisWithPrefix(prefix: string): string[] {
75+
const result: string[] = [];
76+
for (const key of this._sessionStates.keys()) {
77+
if (key.startsWith(prefix)) {
78+
result.push(key);
79+
}
80+
}
81+
return result;
82+
}
83+
7084
// ---- Snapshots ----------------------------------------------------------
7185

7286
/**

0 commit comments

Comments
 (0)