Path: blob/main/src/vs/platform/agentHost/node/agentService.ts
13394 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { decodeBase64, VSBuffer } from '../../../base/common/buffer.js';6import { toErrorMessage } from '../../../base/common/errorMessage.js';7import { Emitter } from '../../../base/common/event.js';8import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';9import { equals as objectEquals } from '../../../base/common/objects.js';10import { observableValue } from '../../../base/common/observable.js';11import { URI } from '../../../base/common/uri.js';12import { generateUuid } from '../../../base/common/uuid.js';13import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js';14import { InstantiationService } from '../../instantiation/common/instantiationService.js';15import { ServiceCollection } from '../../instantiation/common/serviceCollection.js';16import { ILogService } from '../../log/common/log.js';17import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js';18import { ISessionDataService } from '../common/sessionDataService.js';19import { ActionType, ActionEnvelope, INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js';20import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js';21import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js';22import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, parseSubagentSessionUri, readSessionGitState, withSessionGitState, type SessionConfigState, type ISessionFileDiff, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js';23import { IProductService } from '../../product/common/productService.js';24import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js';25import { AgentSideEffects } from './agentSideEffects.js';26import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js';27import { ISessionDbUriFields, parseSessionDbUri } from './copilot/fileEditTracker.js';28import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js';29import { AgentHostStateManager } from './agentHostStateManager.js';30import { IAgentHostGitService } from './agentHostGitService.js';3132/**33* The agent service implementation that runs inside the agent-host utility34* process. Dispatches to registered {@link IAgent} instances based35* on the provider identifier in the session configuration.36*/37export class AgentService extends Disposable implements IAgentService {38declare readonly _serviceBrand: undefined;3940/** Protocol: fires when state is mutated by an action. */41private readonly _onDidAction = this._register(new Emitter<ActionEnvelope>());42readonly onDidAction = this._onDidAction.event;4344/** Protocol: fires for ephemeral notifications (sessionAdded/Removed). */45private readonly _onDidNotification = this._register(new Emitter<INotification>());46readonly onDidNotification = this._onDidNotification.event;4748/** Authoritative state manager for the sessions process protocol. */49private readonly _stateManager: AgentHostStateManager;5051/** Exposes the state manager for co-hosting a WebSocket protocol server. */52get stateManager(): AgentHostStateManager { return this._stateManager; }5354/** Exposes the configuration service so agent providers can share root config plumbing. */55get configurationService(): IAgentConfigurationService { return this._configurationService; }5657/** Registered providers keyed by their {@link AgentProvider} id. */58private readonly _providers = new Map<AgentProvider, IAgent>();59/** Maps each active session URI (toString) to its owning provider. */60private readonly _sessionToProvider = new Map<string, AgentProvider>();61/** Subscriptions to provider progress events; cleared when providers change. */62private readonly _providerSubscriptions = this._register(new DisposableStore());63/** Default provider used when no explicit provider is specified. */64private _defaultProvider: AgentProvider | undefined;65/** Observable registered agents, drives `root/agentsChanged` via {@link AgentSideEffects}. */66private readonly _agents = observableValue<readonly IAgent[]>('agents', []);67/** Shared side-effect handler for action dispatch and session lifecycle. */68private readonly _sideEffects: AgentSideEffects;69/** Manages PTY-backed terminals for the agent host protocol. */70private readonly _terminalManager: AgentHostTerminalManager;71private readonly _configurationService: IAgentConfigurationService;7273/** Exposes the terminal manager for use by agent providers. */74get terminalManager(): IAgentHostTerminalManager { return this._terminalManager; }7576constructor(77private readonly _logService: ILogService,78private readonly _fileService: IFileService,79private readonly _sessionDataService: ISessionDataService,80private readonly _productService: IProductService,81private readonly _gitService: IAgentHostGitService,82private readonly _rootConfigResource?: URI,83) {84super();85this._logService.info('AgentService initialized');86this._stateManager = this._register(new AgentHostStateManager(_logService));87this._register(this._stateManager.onDidEmitEnvelope(e => this._onDidAction.fire(e)));88this._register(this._stateManager.onDidEmitNotification(e => this._onDidNotification.fire(e)));8990// Build a local instantiation scope so downstream components can91// consume {@link IAgentConfigurationService} (and later {@link ILogService})92// via DI rather than being plumbed plain-class references.93const configurationService: IAgentConfigurationService = this._register(new AgentConfigurationService(this._stateManager, this._logService, this._rootConfigResource));94this._configurationService = configurationService;95const services = new ServiceCollection(96[ILogService, this._logService],97[IAgentConfigurationService, configurationService],98[IAgentHostGitService, this._gitService],99);100const instantiationService = this._register(new InstantiationService(services, /*strict*/ true));101102this._sideEffects = this._register(instantiationService.createInstance(AgentSideEffects, this._stateManager, {103getAgent: session => this._findProviderForSession(session),104sessionDataService: this._sessionDataService,105agents: this._agents,106onTurnComplete: session => {107const workingDirStr = this._stateManager.getSessionState(session)?.summary.workingDirectory;108this._attachGitState(URI.parse(session), workingDirStr ? URI.parse(workingDirStr) : undefined);109},110}));111112// Terminal management — the terminal manager listens to the state113// manager's action stream and dispatches PTY output back through it.114this._terminalManager = this._register(new AgentHostTerminalManager(this._stateManager, this._logService, this._productService));115}116117// ---- provider registration ----------------------------------------------118119registerProvider(provider: IAgent): void {120if (this._providers.has(provider.id)) {121throw new Error(`Agent provider already registered: ${provider.id}`);122}123this._logService.info(`Registering agent provider: ${provider.id}`);124this._providers.set(provider.id, provider);125this._providerSubscriptions.add(this._sideEffects.registerProgressListener(provider));126if (!this._defaultProvider) {127this._defaultProvider = provider.id;128}129130// Update root state with current agents list131this._updateAgents();132}133134// ---- auth ---------------------------------------------------------------135136async authenticate(params: AuthenticateParams): Promise<AuthenticateResult> {137this._logService.trace(`[AgentService] authenticate called: resource=${params.resource}`);138for (const provider of this._providers.values()) {139const resources = provider.getProtectedResources();140if (resources.some(r => r.resource === params.resource)) {141const accepted = await provider.authenticate(params.resource, params.token);142if (accepted) {143return { authenticated: true };144}145}146}147return { authenticated: false };148}149150// ---- session management -------------------------------------------------151152async listSessions(): Promise<IAgentSessionMetadata[]> {153this._logService.trace('[AgentService] listSessions called');154const results = await Promise.all(155[...this._providers.values()].map(p => p.listSessions())156);157const flat = results.flat();158159// Overlay persisted custom titles from per-session databases.160const result = await Promise.all(flat.map(async s => {161try {162const ref = await this._sessionDataService.tryOpenDatabase(s.session);163if (!ref) {164return s;165}166try {167const m = await ref.object.getMetadataObject({ customTitle: true, isRead: true, isArchived: true, isDone: true, diffs: true });168let updated = s;169if (m.customTitle) {170updated = { ...updated, summary: m.customTitle };171}172if (m.isRead !== undefined) {173updated = { ...updated, isRead: m.isRead === 'true' };174}175if (m.isArchived !== undefined) {176updated = { ...updated, isArchived: m.isArchived === 'true' };177} else if (m.isDone !== undefined) {178updated = { ...updated, isArchived: m.isDone === 'true' };179}180if (m.diffs) {181try { updated = { ...updated, diffs: JSON.parse(m.diffs) }; } catch { /* ignore malformed */ }182}183return updated;184} finally {185ref.dispose();186}187} catch (e) {188this._logService.warn(`[AgentService] Failed to read session metadata overlay for ${s.session}`, e);189}190return s;191}));192193// Overlay live session state from the state manager.194// For the title, prefer the state manager's value when it is195// non-empty, so SDK-sourced titles are not overwritten by the196// initial empty placeholder.197const withStatus = result.map(s => {198const liveState = this._stateManager.getSessionState(s.session.toString());199if (liveState) {200return {201...s,202summary: liveState.summary.title || s.summary,203status: liveState.summary.status,204activity: liveState.summary.activity,205model: liveState.summary.model ?? s.model,206};207}208return s;209});210211this._logService.trace(`[AgentService] listSessions returned ${withStatus.length} sessions`);212return withStatus;213}214215async createSession(config?: IAgentCreateSessionConfig): Promise<URI> {216const providerId = config?.provider ?? this._defaultProvider;217const provider = providerId ? this._providers.get(providerId) : undefined;218if (!provider) {219throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`);220}221222// When forking, build the old→new turn ID mapping before creating the223// session so the agent can use it to remap per-turn data.224if (config?.fork) {225const sourceState = this._stateManager.getSessionState(config.fork.session.toString());226if (sourceState) {227const sourceTurns = sourceState.turns.slice(0, config.fork.turnIndex + 1);228const turnIdMapping = new Map<string, string>();229for (const t of sourceTurns) {230turnIdMapping.set(t.id, generateUuid());231}232config = {233...config,234fork: { ...config.fork, turnIdMapping },235};236}237}238239// Ensure the command auto-approver is ready before any session events240// can arrive. This makes shell command auto-approval fully synchronous.241// Safe to run in parallel with createSession since no events flow until242// sendMessage() is called.243this._logService.trace(`[AgentService] createSession: initializing auto-approver and creating session...`);244const [, created] = await Promise.all([245this._sideEffects.initialize(),246provider.createSession(config),247]);248const session = created.session;249this._logService.trace(`[AgentService] createSession: initialization complete`);250251this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model?.id ?? '(default)'}`);252this._sessionToProvider.set(session.toString(), provider.id);253this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`);254255const sessionConfig = await this._resolveCreatedSessionConfig(provider, config);256257// When forking, populate the new session's protocol state with258// the source session's turns so the client sees the forked history.259if (config?.fork) {260const sourceState = this._stateManager.getSessionState(config.fork.session.toString());261let sourceTurns: Turn[] = [];262if (sourceState && config.fork.turnIdMapping) {263sourceTurns = sourceState.turns.slice(0, config.fork.turnIndex + 1)264.map(t => ({ ...t, id: config!.fork!.turnIdMapping!.get(t.id) ?? generateUuid() }));265}266267const summary: SessionSummary = {268resource: session.toString(),269provider: provider.id,270title: sourceState?.summary.title ?? 'Forked Session',271status: SessionStatus.Idle,272createdAt: Date.now(),273modifiedAt: Date.now(),274...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}),275model: config?.model,276workingDirectory: (created.workingDirectory ?? config.workingDirectory)?.toString(),277};278const state = this._stateManager.createSession(summary);279state.config = sessionConfig;280state.turns = sourceTurns;281state.activeClient = config.activeClient;282} else {283// Create empty state for new sessions284const summary: SessionSummary = {285resource: session.toString(),286provider: provider.id,287title: '',288status: SessionStatus.Idle,289createdAt: Date.now(),290modifiedAt: Date.now(),291...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}),292model: config?.model,293workingDirectory: (created.workingDirectory ?? config?.workingDirectory)?.toString(),294};295const state = this._stateManager.createSession(summary);296state.config = sessionConfig;297state.activeClient = config?.activeClient;298}299// Persist initial config values so a subsequent `restoreSession` can300// re-hydrate them. We persist the full resolved values (not just the301// user's input) so clients can render them on restore without having302// to re-resolve. Mid-session changes are persisted by `AgentSideEffects`303// when handling `SessionConfigChanged`.304if (sessionConfig?.values && Object.keys(sessionConfig.values).length > 0) {305this._persistConfigValues(session, sessionConfig.values);306}307this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: session.toString() });308309// Lazily compute git state for sessions with a working directory;310// attaches under `state._meta.git` once ready.311this._attachGitState(session, created.workingDirectory ?? config?.workingDirectory);312313return session;314}315316/**317* Fire-and-forget probe that resolves the session's git state for its318* working directory (if any) and merges it into `state._meta.git` via319* the state manager. Failures are logged; sessions simply remain without320* git state.321*/322private _attachGitState(session: URI, workingDirectory: URI | undefined): void {323if (!workingDirectory) {324return;325}326this._gitService.getSessionGitState(workingDirectory).then(327gitState => {328if (!gitState) {329return;330}331const sessionKey = session.toString();332const current = this._stateManager.getSessionState(sessionKey)?._meta;333// Skip the action if the computed git state hasn't changed; this is334// called after every turn, so deduping avoids needless action churn.335if (objectEquals(readSessionGitState(current), gitState)) {336return;337}338const next = withSessionGitState(current, gitState);339this._stateManager.setSessionMeta(sessionKey, next);340},341e => {342this._logService.warn(`[AgentService] Failed to compute git state for ${session}`, e);343},344);345}346347private _persistConfigValues(session: URI, values: Record<string, unknown>): void {348let ref;349try {350ref = this._sessionDataService.openDatabase(session);351} catch (err) {352this._logService.warn(`[AgentService] Failed to open session database to persist configValues for ${session.toString()}: ${toErrorMessage(err)}`);353return;354}355ref.object.setMetadata('configValues', JSON.stringify(values)).catch(err => {356this._logService.warn(`[AgentService] Failed to persist configValues for ${session.toString()}: ${toErrorMessage(err)}`);357}).finally(() => {358ref.dispose();359});360}361362private async _resolveCreatedSessionConfig(provider: IAgent, config: IAgentCreateSessionConfig | undefined): Promise<SessionConfigState | undefined> {363if (!config?.config && !config?.workingDirectory) {364return undefined;365}366try {367const resolved = await provider.resolveSessionConfig({368provider: provider.id,369workingDirectory: config.workingDirectory,370config: config.config,371});372return { schema: resolved.schema, values: resolved.values };373} catch (error) {374this._logService.error(`[AgentService] Failed to resolve created session config for provider ${provider.id}`, error);375return config.config ? { schema: { type: 'object', properties: {} }, values: config.config } : undefined;376}377}378379async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<ResolveSessionConfigResult> {380const providerId = params.provider ?? this._defaultProvider;381const provider = providerId ? this._providers.get(providerId) : undefined;382if (!provider) {383throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`);384}385return provider.resolveSessionConfig(params);386}387388async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<SessionConfigCompletionsResult> {389const providerId = params.provider ?? this._defaultProvider;390const provider = providerId ? this._providers.get(providerId) : undefined;391if (!provider) {392throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`);393}394return provider.sessionConfigCompletions(params);395}396397async disposeSession(session: URI): Promise<void> {398this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`);399const provider = this._findProviderForSession(session);400if (provider) {401await provider.disposeSession(session);402this._sessionToProvider.delete(session.toString());403}404// Remove all subagent sessions for this parent405this._sideEffects.removeSubagentSessions(session.toString());406this._stateManager.deleteSession(session.toString());407}408409// ---- Protocol methods ---------------------------------------------------410411async createTerminal(params: CreateTerminalParams): Promise<void> {412await this._terminalManager.createTerminal(params);413}414415async disposeTerminal(terminal: URI): Promise<void> {416this._terminalManager.disposeTerminal(terminal.toString());417}418419async subscribe(resource: URI): Promise<IStateSnapshot> {420this._logService.trace(`[AgentService] subscribe: ${resource.toString()}`);421const resourceStr = resource.toString();422423// Check for terminal state424const terminalState = this._terminalManager.getTerminalState(resourceStr);425if (terminalState) {426return { resource: resourceStr, state: terminalState, fromSeq: this._stateManager.serverSeq };427}428429let snapshot = this._stateManager.getSnapshot(resourceStr);430if (!snapshot) {431// Try subagent restore before regular session restore432const parsed = parseSubagentSessionUri(resourceStr);433if (parsed) {434await this._restoreSubagentSession(resourceStr, parsed.parentSession, parsed.toolCallId);435} else {436await this.restoreSession(resource);437}438snapshot = this._stateManager.getSnapshot(resourceStr);439}440if (!snapshot) {441throw new Error(`Cannot subscribe to unknown resource: ${resourceStr}`);442}443444// Ensure git state has been computed for this session. When the snapshot445// already existed (e.g. seeded by list query, or restored earlier), the446// restore path that normally calls `_attachGitState` is skipped — so447// trigger it lazily here for the first subscriber. `_attachGitState`448// is async and updates `_meta.git` once ready, which clients see via449// the normal state-update stream.450const sessionState = this._stateManager.getSessionState(resourceStr);451if (sessionState && readSessionGitState(sessionState._meta) === undefined) {452const wd = sessionState.summary?.workingDirectory;453this._attachGitState(resource, wd ? URI.parse(wd) : undefined);454}455456return snapshot;457}458459unsubscribe(resource: URI): void {460this._logService.trace(`[AgentService] unsubscribe: ${resource.toString()}`);461// Server-side tracking of per-client subscriptions will be added462// in Phase 4 (multi-client). For now this is a no-op.463}464465dispatchAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {466this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action);467468const origin = { clientId, clientSeq };469this._stateManager.dispatchClientAction(action, origin);470if (action.type === ActionType.RootConfigChanged) {471this._configurationService.persistRootConfig();472}473this._sideEffects.handleAction(action);474}475476async resourceList(uri: URI): Promise<ResourceListResult> {477let stat;478try {479stat = await this._fileService.resolve(uri);480} catch {481throw new ProtocolError(AhpErrorCodes.NotFound, `Directory not found: ${uri.toString()}`);482}483484if (!stat.isDirectory) {485throw new ProtocolError(AhpErrorCodes.NotFound, `Not a directory: ${uri.toString()}`);486}487488const entries: DirectoryEntry[] = (stat.children ?? []).map(child => ({489name: child.name,490type: child.isDirectory ? 'directory' : 'file',491}));492return { entries };493}494495async restoreSession(session: URI): Promise<void> {496const sessionStr = session.toString();497498// Already in state manager - nothing to do.499if (this._stateManager.getSessionState(sessionStr)) {500return;501}502503const agent = this._findProviderForSession(session);504if (!agent) {505throw new ProtocolError(AHP_SESSION_NOT_FOUND, `No agent for session: ${sessionStr}`);506}507508// Verify the session actually exists on the backend to avoid509// creating phantom sessions for made-up URIs.510let allSessions;511try {512allSessions = await agent.listSessions();513} catch (err) {514if (err instanceof ProtocolError) {515throw err;516}517const message = err instanceof Error ? err.message : String(err);518throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to list sessions for ${sessionStr}: ${message}`);519}520const meta = allSessions.find(s => s.session.toString() === sessionStr);521if (!meta) {522throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`);523}524525let turns: readonly Turn[];526try {527turns = await agent.getSessionMessages(session);528} catch (err) {529if (err instanceof ProtocolError) {530throw err;531}532const message = err instanceof Error ? err.message : String(err);533throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to restore session ${sessionStr}: ${message}`);534}535536// Check for persisted metadata in the session database537let title = meta.summary ?? 'Session';538let isRead: boolean | undefined;539let isArchived: boolean | undefined;540let diffs: ISessionFileDiff[] | undefined;541let persistedConfigValues: Record<string, string> | undefined;542const ref = this._sessionDataService.tryOpenDatabase?.(session);543if (ref) {544try {545const db = await ref;546if (db) {547try {548const m = await db.object.getMetadataObject({ customTitle: true, isRead: true, isArchived: true, isDone: true, diffs: true, configValues: true });549if (m.customTitle) {550title = m.customTitle;551}552if (m.isRead !== undefined) {553isRead = m.isRead === 'true';554}555if (m.isArchived !== undefined) {556isArchived = m.isArchived === 'true';557} else if (m.isDone !== undefined) {558isArchived = m.isDone === 'true';559}560if (m.diffs) {561try { diffs = JSON.parse(m.diffs); } catch { /* ignore malformed */ }562}563if (m.configValues) {564try {565persistedConfigValues = JSON.parse(m.configValues);566} catch (err) {567this._logService.warn(`[AgentService] Failed to parse persisted configValues for ${sessionStr}: ${toErrorMessage(err)}`);568}569}570} finally {571db.dispose();572}573}574} catch {575// Best-effort: fall back to agent-provided metadata576}577}578579// Encode isRead/isArchived as status bitmask flags580let status: SessionStatus = SessionStatus.Idle;581if (isRead) {582status |= SessionStatus.IsRead;583}584if (isArchived) {585status |= SessionStatus.IsArchived;586}587588const summary: SessionSummary = {589resource: sessionStr,590provider: agent.id,591title,592status,593createdAt: meta.startTime,594modifiedAt: meta.modifiedTime,595...(meta.project ? { project: { uri: meta.project.uri.toString(), displayName: meta.project.displayName } } : {}),596model: meta.model,597workingDirectory: meta.workingDirectory?.toString(),598diffs,599};600601this._stateManager.restoreSession(summary, [...turns]);602603// Restore persisted `_meta` (e.g. git state) onto the new session604// state. This dispatches a SessionMetaChanged action.605if (meta._meta) {606this._stateManager.setSessionMeta(sessionStr, meta._meta);607}608609// Resolve the session config so clients (e.g. the running-session610// auto-approve picker) can render session-mutable properties for611// sessions that were not created in the current process lifetime.612// Overlay any values the user previously selected (persisted via613// `SessionConfigChanged`) on top of the provider's resolved defaults.614const restoredConfig = await this._resolveCreatedSessionConfig(agent, {615workingDirectory: meta.workingDirectory,616config: persistedConfigValues,617});618if (restoredConfig) {619const restoredState = this._stateManager.getSessionState(sessionStr);620if (restoredState) {621restoredState.config = restoredConfig;622}623}624625this._logService.info(`[AgentService] Restored session ${sessionStr} with ${turns.length} turns`);626627// Lazily compute git state for sessions with a working directory;628// attaches under `state._meta.git` once ready.629this._attachGitState(session, meta.workingDirectory);630}631632async resourceRead(uri: URI): Promise<ResourceReadResult> {633// Handle session-db: URIs that reference file-edit content stored634// in a per-session SQLite database.635const dbFields = parseSessionDbUri(uri.toString());636if (dbFields) {637return this._fetchSessionDbContent(dbFields);638}639640// Handle git-blob: URIs that reference file content at a specific641// git commit (the merge-base used as diff baseline). The URI642// encodes the session it belongs to so we can find the right643// working directory to run `git show` from.644const blobFields = parseGitBlobUri(uri.toString());645if (blobFields) {646return this._fetchGitBlobContent(blobFields);647}648649try {650const content = await this._fileService.readFile(uri);651return {652data: content.value.toString(),653encoding: ContentEncoding.Utf8,654contentType: 'text/plain',655};656} catch (_e) {657throw new ProtocolError(AhpErrorCodes.NotFound, `Content not found: ${uri.toString()}`);658}659}660661async resourceWrite(params: ResourceWriteParams): Promise<ResourceWriteResult> {662const fileUri = typeof params.uri === 'string' ? URI.parse(params.uri) : URI.revive(params.uri);663let content: VSBuffer;664if (params.encoding === ContentEncoding.Base64) {665content = decodeBase64(params.data);666} else {667content = VSBuffer.fromString(params.data);668}669try {670if (params.createOnly) {671await this._fileService.createFile(fileUri, content, { overwrite: false });672} else {673await this._fileService.writeFile(fileUri, content);674}675return {};676} catch (e) {677const code = toFileSystemProviderErrorCode(e as Error);678if (code === FileSystemProviderErrorCode.FileExists) {679throw new ProtocolError(AhpErrorCodes.AlreadyExists, `File already exists: ${fileUri.toString()}`);680}681if (code === FileSystemProviderErrorCode.NoPermissions) {682throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${fileUri.toString()}`);683}684throw new ProtocolError(AhpErrorCodes.NotFound, `Failed to write file: ${fileUri.toString()}`);685}686}687688async resourceCopy(params: ResourceCopyParams): Promise<ResourceCopyResult> {689const source = URI.parse(params.source);690const destination = URI.parse(params.destination);691try {692await this._fileService.copy(source, destination, !params.failIfExists);693return {};694} catch (e) {695const code = toFileSystemProviderErrorCode(e as Error);696if (code === FileSystemProviderErrorCode.FileExists) {697throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Destination already exists: ${destination.toString()}`);698}699if (code === FileSystemProviderErrorCode.NoPermissions) {700throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${source.toString()}`);701}702throw new ProtocolError(AhpErrorCodes.NotFound, `Source not found: ${source.toString()}`);703}704}705706async resourceDelete(params: ResourceDeleteParams): Promise<ResourceDeleteResult> {707const fileUri = URI.parse(params.uri);708try {709await this._fileService.del(fileUri, { recursive: params.recursive });710return {};711} catch (e) {712const code = toFileSystemProviderErrorCode(e as Error);713if (code === FileSystemProviderErrorCode.NoPermissions) {714throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${fileUri.toString()}`);715}716throw new ProtocolError(AhpErrorCodes.NotFound, `Resource not found: ${fileUri.toString()}`);717}718}719720async resourceMove(params: ResourceMoveParams): Promise<ResourceMoveResult> {721const source = URI.parse(params.source);722const destination = URI.parse(params.destination);723try {724await this._fileService.move(source, destination, !params.failIfExists);725return {};726} catch (e) {727const code = toFileSystemProviderErrorCode(e as Error);728if (code === FileSystemProviderErrorCode.FileExists) {729throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Destination already exists: ${destination.toString()}`);730}731if (code === FileSystemProviderErrorCode.NoPermissions) {732throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${source.toString()}`);733}734throw new ProtocolError(AhpErrorCodes.NotFound, `Source not found: ${source.toString()}`);735}736}737738async shutdown(): Promise<void> {739this._logService.info('AgentService: shutting down all providers...');740const promises: Promise<void>[] = [];741for (const provider of this._providers.values()) {742promises.push(provider.shutdown());743}744await Promise.all(promises);745this._sessionToProvider.clear();746}747748// ---- helpers ------------------------------------------------------------749750private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise<ResourceReadResult> {751const sessionUri = URI.parse(fields.sessionUri);752const ref = this._sessionDataService.openDatabase(sessionUri);753try {754const content = await ref.object.readFileEditContent(fields.toolCallId, fields.filePath);755if (!content) {756throw new ProtocolError(AhpErrorCodes.NotFound, `File edit not found: toolCallId=${fields.toolCallId}, filePath=${fields.filePath}`);757}758const bytes = fields.part === 'before' ? content.beforeContent : content.afterContent;759if (!bytes) {760throw new ProtocolError(AhpErrorCodes.NotFound, `No ${fields.part} content for: toolCallId=${fields.toolCallId}, filePath=${fields.filePath}`);761}762return {763data: new TextDecoder().decode(bytes),764encoding: ContentEncoding.Utf8,765contentType: 'text/plain',766};767} finally {768ref.dispose();769}770}771772private async _fetchGitBlobContent(fields: IGitBlobUriFields): Promise<ResourceReadResult> {773if (!this._gitService) {774throw new ProtocolError(AhpErrorCodes.NotFound, `git service unavailable for: ${fields.repoRelativePath}`);775}776const workingDirectory = this._stateManager.getSessionState(fields.sessionUri)?.summary.workingDirectory;777if (!workingDirectory) {778throw new ProtocolError(AhpErrorCodes.NotFound, `Session has no working directory for git-blob URI: ${fields.sessionUri}`);779}780const blob = await this._gitService.showBlob(URI.parse(workingDirectory), fields.sha, fields.repoRelativePath);781if (!blob) {782throw new ProtocolError(AhpErrorCodes.NotFound, `git blob not found: ${fields.sha}:${fields.repoRelativePath}`);783}784return {785data: blob.toString(),786encoding: ContentEncoding.Utf8,787contentType: 'text/plain',788};789}790791/**792* Restores a subagent session from its parent session's event history.793* Loads the parent's raw messages, filters for events belonging to794* the subagent (by `parentToolCallId`), and builds the child session's795* turns from those events.796*/797private async _restoreSubagentSession(subagentUri: string, parentSession: string, toolCallId: string): Promise<void> {798// Ensure the parent session is loaded first799const parentUri = URI.parse(parentSession);800if (!this._stateManager.getSessionState(parentSession)) {801try {802await this.restoreSession(parentUri);803} catch {804this._logService.warn(`[AgentService] Cannot restore parent session for subagent: ${parentSession}`);805return;806}807}808809const parentState = this._stateManager.getSessionState(parentSession);810if (!parentState) {811return;812}813814// Search completed turns and active turn for the subagent content metadata815const allTurns = [...parentState.turns];816if (parentState.activeTurn) {817allTurns.push(parentState.activeTurn as Turn);818}819820let subagentContent: ToolResultSubagentContent | undefined;821for (const turn of allTurns) {822for (const part of turn.responseParts) {823if (part.kind === ResponsePartKind.ToolCall) {824const tc = part.toolCall;825// Check both completed and running tool calls — running826// tool calls receive subagent content via ContentChanged827const content = tc.status === ToolCallStatus.Completed828? tc.content829: (tc.status === ToolCallStatus.Running ? tc.content : undefined);830if (content) {831for (const c of content) {832if (c.type === ToolResultContentType.Subagent && c.resource === subagentUri) {833subagentContent = c;834break;835}836}837}838}839}840if (subagentContent) {841break;842}843}844845// Load the subagent's turns from the agent (which knows how to846// extract them from the parent session's event log).847let childTurns: readonly Turn[] = [];848const agent = this._findProviderForSession(parentUri);849if (agent) {850try {851childTurns = await agent.getSessionMessages(URI.parse(subagentUri));852} catch (err) {853this._logService.warn(`[AgentService] Failed to load subagent turns for ${subagentUri}`, err);854}855}856857// Use metadata from subagent content if available, otherwise synthesize858const title = subagentContent?.title ?? 'Subagent';859860this._stateManager.restoreSession(861{862resource: subagentUri,863provider: 'subagent',864title,865status: SessionStatus.Idle,866createdAt: Date.now(),867modifiedAt: Date.now(),868...(parentState?.summary.project ? { project: parentState.summary.project } : {}),869},870[...childTurns],871);872this._logService.info(`[AgentService] Restored subagent session: ${subagentUri} with ${childTurns.length} turn(s)`);873}874875private _findProviderForSession(session: URI | string): IAgent | undefined {876const key = typeof session === 'string' ? session : session.toString();877const providerId = this._sessionToProvider.get(key);878if (providerId) {879return this._providers.get(providerId);880}881const schemeProvider = AgentSession.provider(session);882if (schemeProvider) {883return this._providers.get(schemeProvider);884}885// Fallback: try the default provider (handles resumed sessions not yet tracked)886if (this._defaultProvider) {887return this._providers.get(this._defaultProvider);888}889return undefined;890}891892/**893* Sets the agents observable to trigger model re-fetch and894* `root/agentsChanged` via the autorun in {@link AgentSideEffects}.895*/896private _updateAgents(): void {897this._agents.set([...this._providers.values()], undefined);898}899900override dispose(): void {901for (const provider of this._providers.values()) {902provider.dispose();903}904this._providers.clear();905super.dispose();906}907}908909910