Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/vs/platform/agentHost/common/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,22 @@ export interface IMcpNotification {
readonly params?: Record<string, unknown>;
}

/**
* A subagent child session discovered in a parent session's event log,
* returned by {@link IAgent.getSubagentSessions} so a parent restore can
* register the child's state up-front.
*/
export interface IRestoredSubagentSession {
/** Child subagent session URI (subscribable by clients). */
readonly resource: URI;
/** Parent tool call id that spawned the subagent. */
readonly toolCallId: string;
/** Display title for the subagent session. */
readonly title: string;
/** Reconstructed turns for the subagent's transcript. */
readonly turns: readonly Turn[];
}

/**
* A per-session handle for one active client's contributions (tools and
* plugin customizations) to an agent session, obtained via
Expand Down Expand Up @@ -975,6 +991,17 @@ export interface IAgent {
*/
getSessionMessages(session: URI): Promise<readonly Turn[]>;

/**
* Returns the subagent child sessions discoverable in a session's event
* log so a parent restore can eagerly register them in a single pass.
* Without this, every child is restored separately by re-fetching and
* re-reconstructing the full parent event log (one pass per subagent).
* Agents that serve this from the same reconstruction they already
* produced for the parent turns avoid that redundant work entirely.
* Optional; agents without subagents omit it.
*/
getSubagentSessions?(session: URI): Promise<readonly IRestoredSubagentSession[]>;

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

Expand Down
44 changes: 43 additions & 1 deletion src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { FileChangeType, FileOperationError, FileOperationResult, FileSystemProv
import { InstantiationService } from '../../instantiation/common/instantiationService.js';
import { ServiceCollection } from '../../instantiation/common/serviceCollection.js';
import { ILogService } from '../../log/common/log.js';
import { AgentProvider, AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostAuthTokenRequest, IAgentMaterializeSessionEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agentService.js';
import { AgentProvider, AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostAuthTokenRequest, IAgentMaterializeSessionEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification, IRestoredSubagentSession } from '../common/agentService.js';
import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js';
import { buildDefaultChangesetCatalogue, parseChangesetUri } from '../common/changesetUri.js';
import { ActionType, ActionEnvelope, INotification, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction } from '../common/state/sessionActions.js';
Expand Down Expand Up @@ -1610,6 +1610,23 @@ export class AgentService extends Disposable implements IAgentService {

this._stateManager.restoreSession(summary, [...turns]);

// Eagerly register subagent child sessions discovered in the event log
// so the client's per-subagent subscriptions resolve from in-memory
// state (hitting `restoreSubagent skipped existing`) instead of each
// re-fetching and re-reconstructing the full parent event log. The
// agent serves these from the same reconstruction it already produced
// for the parent turns above, so this adds no extra event-log reads.
if (agent.getSubagentSessions) {
try {
const children = await agent.getSubagentSessions(session);
for (const child of children) {
this._registerRestoredSubagent(child, summary);
}
} catch (err) {
this._logService.warn(`[AgentService] restoreSession failed to eagerly register subagents session=${sessionStr}`, err);
}
}

// Restore any additional (non-default) peer chats the provider has
// persisted for this session, seeding each with its own history and
// persisted title so they reappear after a process restart.
Expand Down Expand Up @@ -2295,6 +2312,31 @@ export class AgentService extends Disposable implements IAgentService {
this._logService.info(`[AgentService] Restored subagent session: ${subagentUri} with ${childTurns.length} turn(s)`);
}

/**
* Registers a subagent child session's state up-front from data the agent
* already reconstructed for the parent, so a later subscribe-driven
* {@link _restoreSubagentSession} finds it present and returns early
* instead of re-reading the parent event log. No-op if already registered.
*/
private _registerRestoredSubagent(child: IRestoredSubagentSession, parentSummary: SessionSummary): void {
const resourceStr = child.resource.toString();
if (this._stateManager.getSessionState(resourceStr)) {
return;
}
this._stateManager.restoreSession(
{
resource: resourceStr,
provider: 'subagent',
title: child.title,
status: SessionStatus.Idle,
createdAt: Date.now(),
modifiedAt: Date.now(),
...(parentSummary.project ? { project: parentSummary.project } : {}),
},
[...child.turns],
);
}

private _findProviderForSession(session: URI | string): IAgent | undefined {
const key = typeof session === 'string' ? session : session.toString();
const providerId = this._sessionToProvider.get(key);
Expand Down
24 changes: 23 additions & 1 deletion src/vs/platform/agentHost/node/copilot/copilotAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { createPricingMetaFromBilling, type ICAPIModelBilling } from '../../comm
import { AgentHostConfigKey, agentHostCustomizationConfigSchema, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js';
import { AgentHostMcpServersConfigKey, AgentHostSessionSyncEnabledConfigKey, AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, schemaProperty, type AgentHostMcpServers } from '../../common/agentHostSchema.js';
import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js';
import { AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IActiveClient, IAgent, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IMcpNotification } from '../../common/agentService.js';
import { AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IActiveClient, IAgent, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IMcpNotification, IRestoredSubagentSession } from '../../common/agentService.js';
import { getEffectiveAgents } from '../../common/customAgents.js';
import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../common/reasoningEffort.js';
import type { IAgentServerToolHost } from '../../common/agentServerTools.js';
Expand Down Expand Up @@ -1613,6 +1613,28 @@ export class CopilotAgent extends Disposable implements IAgent {
return prependAnnouncementToFirstTurn(rawTurns, buildWorktreeAnnouncementText(worktreeMeta.branchName));
}

async getSubagentSessions(session: URI): Promise<readonly IRestoredSubagentSession[]> {
// Only the root SDK session entry owns the event log; peer-chat and
// subagent URIs are derived from it and have no subagents of their own.
const chatInfo = parseChatUri(session);
if (chatInfo && !isDefaultChatUri(session)) {
return [];
}
if (parseSubagentSessionUri(session)) {
return [];
}
const sessionId = AgentSession.id(session);
// Provisional sessions have no SDK history (and thus no subagents) yet.
if (this._provisionalSessions.has(sessionId)) {
return [];
}
const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(err => {
this._logService.warn(`[Copilot:${sessionId}] Failed to resume session for subagent lookup`, err);
return undefined;
});
return entry ? entry.getSubagentSessions() : [];
}

async disposeSession(session: URI): Promise<void> {
const sessionId = AgentSession.id(session);
await this._sessionSequencer.queue(sessionId, async () => {
Expand Down
118 changes: 107 additions & 11 deletions src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../co
import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from '../../common/agentHostPlanReview.js';
import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js';
import { platformSessionSchema } from '../../common/agentHostSchema.js';
import { AgentSignal, IMcpNotification } from '../../common/agentService.js';
import { AgentSignal, IMcpNotification, IRestoredSubagentSession } from '../../common/agentService.js';
import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js';
import { toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta } from '../../common/meta/agentToolCallMeta.js';
import { OtelData, type OtelAttributeValue } from '../../common/otlp/otlpLogEmitter.js';
Expand All @@ -38,7 +38,7 @@ import { isAgentFeedbackAnnotationsAttachment, renderAgentFeedbackAnnotationsAtt
import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../../common/sessionDataService.js';
import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment } from '../../common/state/protocol/state.js';
import { ActionType, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js';
import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type UsageInfo, type UsageInfoMeta } from '../../common/state/sessionState.js';
import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, buildSubagentSessionUri, getToolSubagentContent, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type UsageInfo, type UsageInfoMeta } from '../../common/state/sessionState.js';
import { IAgentConfigurationService } from '../agentConfigurationService.js';
import type { IExitPlanModeResponse } from './copilotAgent.js';
import { CopilotSessionWrapper } from './copilotSessionWrapper.js';
Expand Down Expand Up @@ -74,6 +74,8 @@ const SESSION_STATE_DIRECTORY = join(COPILOT_HOME_DIRECTORY, 'session-state');
const EMPTY_TOOL_RESULT_TEXT = '<empty />';
const RUNTIME_SLASH_COMMAND_CACHE_TTL_MS = 30_000;

type IMappedSessionEvents = { turns: Turn[]; subagentTurnsByToolCallId: ReadonlyMap<string, Turn[]> };

function getEmptyToolResultText(binaryResults: readonly { readonly type: 'image' | 'resource' }[] | undefined): string {
if (!binaryResults?.length) {
return EMPTY_TOOL_RESULT_TEXT;
Expand Down Expand Up @@ -984,6 +986,7 @@ export class CopilotAgentSession extends Disposable {
this._wrapper = this._register(wrapper);
this._subscribeToEvents();
this._subscribeForLogging();
this._subscribeForMemoInvalidation();

// Advertise the agent host's server tools for this session so clients
// see them as server-provided. Execution happens in-process via the SDK
Expand Down Expand Up @@ -1267,18 +1270,80 @@ export class CopilotAgentSession extends Disposable {
}

async getMessages(): Promise<readonly Turn[]> {
const events = await this._wrapper.session.getEvents();
let db: ISessionDatabase | undefined;
try {
db = this._databaseRef.object;
} catch {
// Database may not exist yet — that's fine
}
const result = await mapSessionEvents(this.sessionUri, db, events, this._workingDirectory);
const result = await this._getMappedEvents();
return result.turns;
}

async getSubagentMessages(parentToolCallId: string): Promise<readonly Turn[]> {
const result = await this._getMappedEvents();
const turns = result.subagentTurnsByToolCallId.get(parentToolCallId) ?? [];
return turns;
}

/**
* Returns the subagent child sessions discoverable in this session's event
* log, derived from the same {@link mapSessionEvents} reconstruction used
* for {@link getMessages}/{@link getSubagentMessages}. Lets a parent
* restore register every child up-front instead of each child re-fetching
* and re-reconstructing the full parent event log.
*/
async getSubagentSessions(): Promise<readonly IRestoredSubagentSession[]> {
const result = await this._getMappedEvents();
if (result.subagentTurnsByToolCallId.size === 0) {
return [];
}
const parentSessionStr = this.sessionUri.toString();
const out: IRestoredSubagentSession[] = [];
for (const turn of result.turns) {
for (const rp of turn.responseParts) {
if (rp.kind !== ResponsePartKind.ToolCall) {
continue;
}
const tc = rp.toolCall;
const childTurns = result.subagentTurnsByToolCallId.get(tc.toolCallId);
if (!childTurns || childTurns.length === 0) {
continue;
}
const content = (tc as { content?: readonly ToolResultContent[] }).content;
const subagentContent = content ? getToolSubagentContent({ content }) : undefined;
out.push({
resource: URI.parse(buildSubagentSessionUri(parentSessionStr, tc.toolCallId)),
toolCallId: tc.toolCallId,
title: subagentContent?.title ?? 'Subagent',
turns: childTurns,
});
}
}
return out;
}

/**
* Memoized `getEvents()` + {@link mapSessionEvents} result, shared by
* {@link getMessages}, {@link getSubagentMessages} and
* {@link getSubagentSessions}. A single session open reads and
* reconstructs the full parent event log once instead of once per
* subagent. The memo is scoped to the resume/restore wave: it is dropped
* whenever the persisted event log could change (see
* {@link _invalidateMappedEvents}) and on dispose, so it never serves
* stale turns for an actively-running session.
*/
private _mappedEventsMemo: Promise<IMappedSessionEvents> | undefined;

private _getMappedEvents(): Promise<IMappedSessionEvents> {
if (!this._mappedEventsMemo) {
const pending = this._computeMappedEvents();
this._mappedEventsMemo = pending;
// Don't cache a rejected reconstruction — let the next caller retry.
pending.catch(() => {
if (this._mappedEventsMemo === pending) {
this._mappedEventsMemo = undefined;
}
});
}
return this._mappedEventsMemo;
}

private async _computeMappedEvents(): Promise<IMappedSessionEvents> {
const events = await this._wrapper.session.getEvents();
let db: ISessionDatabase | undefined;
try {
Expand All @@ -1287,7 +1352,12 @@ export class CopilotAgentSession extends Disposable {
// Database may not exist yet — that's fine
}
const result = await mapSessionEvents(this.sessionUri, db, events, this._workingDirectory);
return result.subagentTurnsByToolCallId.get(parentToolCallId) ?? [];
return result;
}

/** Drop the memoized event reconstruction; the next read rebuilds it. */
private _invalidateMappedEvents(): void {
this._mappedEventsMemo = undefined;
}

async abort(): Promise<void> {
Expand Down Expand Up @@ -2834,6 +2904,32 @@ export class CopilotAgentSession extends Disposable {
}
}

/**
* Drop the memoized event reconstruction whenever the persisted event log
* could have changed, so {@link _getMappedEvents} never serves stale turns
* once the session resumes activity. While the session is idle (e.g. during
* a historical session open) none of these fire, so the whole restore wave
* coalesces to a single reconstruction.
*/
private _subscribeForMemoInvalidation(): void {
const wrapper = this._wrapper;
const invalidate = () => this._invalidateMappedEvents();
// New content appended to the log.
this._register(wrapper.onUserMessage(invalidate));
this._register(wrapper.onTurnStart(invalidate));
this._register(wrapper.onMessage(invalidate));
this._register(wrapper.onToolStart(invalidate));
this._register(wrapper.onToolComplete(invalidate));
this._register(wrapper.onSubagentStarted(invalidate));
this._register(wrapper.onSubagentCompleted(invalidate));
this._register(wrapper.onSubagentFailed(invalidate));
this._register(wrapper.onTurnEnd(invalidate));
// In-place rewrites of the persisted log.
this._register(wrapper.onSessionCompactionComplete(invalidate));
this._register(wrapper.onSessionTruncation(invalidate));
this._register(wrapper.onSessionSnapshotRewind(invalidate));
}

private _subscribeForLogging(): void {
const wrapper = this._wrapper;
const sessionId = this.sessionId;
Expand Down
Loading
Loading