Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/node/agentService.ts
13394 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { decodeBase64, VSBuffer } from '../../../base/common/buffer.js';
7
import { toErrorMessage } from '../../../base/common/errorMessage.js';
8
import { Emitter } from '../../../base/common/event.js';
9
import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';
10
import { equals as objectEquals } from '../../../base/common/objects.js';
11
import { observableValue } from '../../../base/common/observable.js';
12
import { URI } from '../../../base/common/uri.js';
13
import { generateUuid } from '../../../base/common/uuid.js';
14
import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js';
15
import { InstantiationService } from '../../instantiation/common/instantiationService.js';
16
import { ServiceCollection } from '../../instantiation/common/serviceCollection.js';
17
import { ILogService } from '../../log/common/log.js';
18
import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js';
19
import { ISessionDataService } from '../common/sessionDataService.js';
20
import { ActionType, ActionEnvelope, INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js';
21
import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js';
22
import { 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';
23
import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, parseSubagentSessionUri, readSessionGitState, withSessionGitState, type SessionConfigState, type ISessionFileDiff, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js';
24
import { IProductService } from '../../product/common/productService.js';
25
import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js';
26
import { AgentSideEffects } from './agentSideEffects.js';
27
import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js';
28
import { ISessionDbUriFields, parseSessionDbUri } from './copilot/fileEditTracker.js';
29
import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js';
30
import { AgentHostStateManager } from './agentHostStateManager.js';
31
import { IAgentHostGitService } from './agentHostGitService.js';
32
33
/**
34
* The agent service implementation that runs inside the agent-host utility
35
* process. Dispatches to registered {@link IAgent} instances based
36
* on the provider identifier in the session configuration.
37
*/
38
export class AgentService extends Disposable implements IAgentService {
39
declare readonly _serviceBrand: undefined;
40
41
/** Protocol: fires when state is mutated by an action. */
42
private readonly _onDidAction = this._register(new Emitter<ActionEnvelope>());
43
readonly onDidAction = this._onDidAction.event;
44
45
/** Protocol: fires for ephemeral notifications (sessionAdded/Removed). */
46
private readonly _onDidNotification = this._register(new Emitter<INotification>());
47
readonly onDidNotification = this._onDidNotification.event;
48
49
/** Authoritative state manager for the sessions process protocol. */
50
private readonly _stateManager: AgentHostStateManager;
51
52
/** Exposes the state manager for co-hosting a WebSocket protocol server. */
53
get stateManager(): AgentHostStateManager { return this._stateManager; }
54
55
/** Exposes the configuration service so agent providers can share root config plumbing. */
56
get configurationService(): IAgentConfigurationService { return this._configurationService; }
57
58
/** Registered providers keyed by their {@link AgentProvider} id. */
59
private readonly _providers = new Map<AgentProvider, IAgent>();
60
/** Maps each active session URI (toString) to its owning provider. */
61
private readonly _sessionToProvider = new Map<string, AgentProvider>();
62
/** Subscriptions to provider progress events; cleared when providers change. */
63
private readonly _providerSubscriptions = this._register(new DisposableStore());
64
/** Default provider used when no explicit provider is specified. */
65
private _defaultProvider: AgentProvider | undefined;
66
/** Observable registered agents, drives `root/agentsChanged` via {@link AgentSideEffects}. */
67
private readonly _agents = observableValue<readonly IAgent[]>('agents', []);
68
/** Shared side-effect handler for action dispatch and session lifecycle. */
69
private readonly _sideEffects: AgentSideEffects;
70
/** Manages PTY-backed terminals for the agent host protocol. */
71
private readonly _terminalManager: AgentHostTerminalManager;
72
private readonly _configurationService: IAgentConfigurationService;
73
74
/** Exposes the terminal manager for use by agent providers. */
75
get terminalManager(): IAgentHostTerminalManager { return this._terminalManager; }
76
77
constructor(
78
private readonly _logService: ILogService,
79
private readonly _fileService: IFileService,
80
private readonly _sessionDataService: ISessionDataService,
81
private readonly _productService: IProductService,
82
private readonly _gitService: IAgentHostGitService,
83
private readonly _rootConfigResource?: URI,
84
) {
85
super();
86
this._logService.info('AgentService initialized');
87
this._stateManager = this._register(new AgentHostStateManager(_logService));
88
this._register(this._stateManager.onDidEmitEnvelope(e => this._onDidAction.fire(e)));
89
this._register(this._stateManager.onDidEmitNotification(e => this._onDidNotification.fire(e)));
90
91
// Build a local instantiation scope so downstream components can
92
// consume {@link IAgentConfigurationService} (and later {@link ILogService})
93
// via DI rather than being plumbed plain-class references.
94
const configurationService: IAgentConfigurationService = this._register(new AgentConfigurationService(this._stateManager, this._logService, this._rootConfigResource));
95
this._configurationService = configurationService;
96
const services = new ServiceCollection(
97
[ILogService, this._logService],
98
[IAgentConfigurationService, configurationService],
99
[IAgentHostGitService, this._gitService],
100
);
101
const instantiationService = this._register(new InstantiationService(services, /*strict*/ true));
102
103
this._sideEffects = this._register(instantiationService.createInstance(AgentSideEffects, this._stateManager, {
104
getAgent: session => this._findProviderForSession(session),
105
sessionDataService: this._sessionDataService,
106
agents: this._agents,
107
onTurnComplete: session => {
108
const workingDirStr = this._stateManager.getSessionState(session)?.summary.workingDirectory;
109
this._attachGitState(URI.parse(session), workingDirStr ? URI.parse(workingDirStr) : undefined);
110
},
111
}));
112
113
// Terminal management — the terminal manager listens to the state
114
// manager's action stream and dispatches PTY output back through it.
115
this._terminalManager = this._register(new AgentHostTerminalManager(this._stateManager, this._logService, this._productService));
116
}
117
118
// ---- provider registration ----------------------------------------------
119
120
registerProvider(provider: IAgent): void {
121
if (this._providers.has(provider.id)) {
122
throw new Error(`Agent provider already registered: ${provider.id}`);
123
}
124
this._logService.info(`Registering agent provider: ${provider.id}`);
125
this._providers.set(provider.id, provider);
126
this._providerSubscriptions.add(this._sideEffects.registerProgressListener(provider));
127
if (!this._defaultProvider) {
128
this._defaultProvider = provider.id;
129
}
130
131
// Update root state with current agents list
132
this._updateAgents();
133
}
134
135
// ---- auth ---------------------------------------------------------------
136
137
async authenticate(params: AuthenticateParams): Promise<AuthenticateResult> {
138
this._logService.trace(`[AgentService] authenticate called: resource=${params.resource}`);
139
for (const provider of this._providers.values()) {
140
const resources = provider.getProtectedResources();
141
if (resources.some(r => r.resource === params.resource)) {
142
const accepted = await provider.authenticate(params.resource, params.token);
143
if (accepted) {
144
return { authenticated: true };
145
}
146
}
147
}
148
return { authenticated: false };
149
}
150
151
// ---- session management -------------------------------------------------
152
153
async listSessions(): Promise<IAgentSessionMetadata[]> {
154
this._logService.trace('[AgentService] listSessions called');
155
const results = await Promise.all(
156
[...this._providers.values()].map(p => p.listSessions())
157
);
158
const flat = results.flat();
159
160
// Overlay persisted custom titles from per-session databases.
161
const result = await Promise.all(flat.map(async s => {
162
try {
163
const ref = await this._sessionDataService.tryOpenDatabase(s.session);
164
if (!ref) {
165
return s;
166
}
167
try {
168
const m = await ref.object.getMetadataObject({ customTitle: true, isRead: true, isArchived: true, isDone: true, diffs: true });
169
let updated = s;
170
if (m.customTitle) {
171
updated = { ...updated, summary: m.customTitle };
172
}
173
if (m.isRead !== undefined) {
174
updated = { ...updated, isRead: m.isRead === 'true' };
175
}
176
if (m.isArchived !== undefined) {
177
updated = { ...updated, isArchived: m.isArchived === 'true' };
178
} else if (m.isDone !== undefined) {
179
updated = { ...updated, isArchived: m.isDone === 'true' };
180
}
181
if (m.diffs) {
182
try { updated = { ...updated, diffs: JSON.parse(m.diffs) }; } catch { /* ignore malformed */ }
183
}
184
return updated;
185
} finally {
186
ref.dispose();
187
}
188
} catch (e) {
189
this._logService.warn(`[AgentService] Failed to read session metadata overlay for ${s.session}`, e);
190
}
191
return s;
192
}));
193
194
// Overlay live session state from the state manager.
195
// For the title, prefer the state manager's value when it is
196
// non-empty, so SDK-sourced titles are not overwritten by the
197
// initial empty placeholder.
198
const withStatus = result.map(s => {
199
const liveState = this._stateManager.getSessionState(s.session.toString());
200
if (liveState) {
201
return {
202
...s,
203
summary: liveState.summary.title || s.summary,
204
status: liveState.summary.status,
205
activity: liveState.summary.activity,
206
model: liveState.summary.model ?? s.model,
207
};
208
}
209
return s;
210
});
211
212
this._logService.trace(`[AgentService] listSessions returned ${withStatus.length} sessions`);
213
return withStatus;
214
}
215
216
async createSession(config?: IAgentCreateSessionConfig): Promise<URI> {
217
const providerId = config?.provider ?? this._defaultProvider;
218
const provider = providerId ? this._providers.get(providerId) : undefined;
219
if (!provider) {
220
throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`);
221
}
222
223
// When forking, build the old→new turn ID mapping before creating the
224
// session so the agent can use it to remap per-turn data.
225
if (config?.fork) {
226
const sourceState = this._stateManager.getSessionState(config.fork.session.toString());
227
if (sourceState) {
228
const sourceTurns = sourceState.turns.slice(0, config.fork.turnIndex + 1);
229
const turnIdMapping = new Map<string, string>();
230
for (const t of sourceTurns) {
231
turnIdMapping.set(t.id, generateUuid());
232
}
233
config = {
234
...config,
235
fork: { ...config.fork, turnIdMapping },
236
};
237
}
238
}
239
240
// Ensure the command auto-approver is ready before any session events
241
// can arrive. This makes shell command auto-approval fully synchronous.
242
// Safe to run in parallel with createSession since no events flow until
243
// sendMessage() is called.
244
this._logService.trace(`[AgentService] createSession: initializing auto-approver and creating session...`);
245
const [, created] = await Promise.all([
246
this._sideEffects.initialize(),
247
provider.createSession(config),
248
]);
249
const session = created.session;
250
this._logService.trace(`[AgentService] createSession: initialization complete`);
251
252
this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model?.id ?? '(default)'}`);
253
this._sessionToProvider.set(session.toString(), provider.id);
254
this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`);
255
256
const sessionConfig = await this._resolveCreatedSessionConfig(provider, config);
257
258
// When forking, populate the new session's protocol state with
259
// the source session's turns so the client sees the forked history.
260
if (config?.fork) {
261
const sourceState = this._stateManager.getSessionState(config.fork.session.toString());
262
let sourceTurns: Turn[] = [];
263
if (sourceState && config.fork.turnIdMapping) {
264
sourceTurns = sourceState.turns.slice(0, config.fork.turnIndex + 1)
265
.map(t => ({ ...t, id: config!.fork!.turnIdMapping!.get(t.id) ?? generateUuid() }));
266
}
267
268
const summary: SessionSummary = {
269
resource: session.toString(),
270
provider: provider.id,
271
title: sourceState?.summary.title ?? 'Forked Session',
272
status: SessionStatus.Idle,
273
createdAt: Date.now(),
274
modifiedAt: Date.now(),
275
...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}),
276
model: config?.model,
277
workingDirectory: (created.workingDirectory ?? config.workingDirectory)?.toString(),
278
};
279
const state = this._stateManager.createSession(summary);
280
state.config = sessionConfig;
281
state.turns = sourceTurns;
282
state.activeClient = config.activeClient;
283
} else {
284
// Create empty state for new sessions
285
const summary: SessionSummary = {
286
resource: session.toString(),
287
provider: provider.id,
288
title: '',
289
status: SessionStatus.Idle,
290
createdAt: Date.now(),
291
modifiedAt: Date.now(),
292
...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}),
293
model: config?.model,
294
workingDirectory: (created.workingDirectory ?? config?.workingDirectory)?.toString(),
295
};
296
const state = this._stateManager.createSession(summary);
297
state.config = sessionConfig;
298
state.activeClient = config?.activeClient;
299
}
300
// Persist initial config values so a subsequent `restoreSession` can
301
// re-hydrate them. We persist the full resolved values (not just the
302
// user's input) so clients can render them on restore without having
303
// to re-resolve. Mid-session changes are persisted by `AgentSideEffects`
304
// when handling `SessionConfigChanged`.
305
if (sessionConfig?.values && Object.keys(sessionConfig.values).length > 0) {
306
this._persistConfigValues(session, sessionConfig.values);
307
}
308
this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: session.toString() });
309
310
// Lazily compute git state for sessions with a working directory;
311
// attaches under `state._meta.git` once ready.
312
this._attachGitState(session, created.workingDirectory ?? config?.workingDirectory);
313
314
return session;
315
}
316
317
/**
318
* Fire-and-forget probe that resolves the session's git state for its
319
* working directory (if any) and merges it into `state._meta.git` via
320
* the state manager. Failures are logged; sessions simply remain without
321
* git state.
322
*/
323
private _attachGitState(session: URI, workingDirectory: URI | undefined): void {
324
if (!workingDirectory) {
325
return;
326
}
327
this._gitService.getSessionGitState(workingDirectory).then(
328
gitState => {
329
if (!gitState) {
330
return;
331
}
332
const sessionKey = session.toString();
333
const current = this._stateManager.getSessionState(sessionKey)?._meta;
334
// Skip the action if the computed git state hasn't changed; this is
335
// called after every turn, so deduping avoids needless action churn.
336
if (objectEquals(readSessionGitState(current), gitState)) {
337
return;
338
}
339
const next = withSessionGitState(current, gitState);
340
this._stateManager.setSessionMeta(sessionKey, next);
341
},
342
e => {
343
this._logService.warn(`[AgentService] Failed to compute git state for ${session}`, e);
344
},
345
);
346
}
347
348
private _persistConfigValues(session: URI, values: Record<string, unknown>): void {
349
let ref;
350
try {
351
ref = this._sessionDataService.openDatabase(session);
352
} catch (err) {
353
this._logService.warn(`[AgentService] Failed to open session database to persist configValues for ${session.toString()}: ${toErrorMessage(err)}`);
354
return;
355
}
356
ref.object.setMetadata('configValues', JSON.stringify(values)).catch(err => {
357
this._logService.warn(`[AgentService] Failed to persist configValues for ${session.toString()}: ${toErrorMessage(err)}`);
358
}).finally(() => {
359
ref.dispose();
360
});
361
}
362
363
private async _resolveCreatedSessionConfig(provider: IAgent, config: IAgentCreateSessionConfig | undefined): Promise<SessionConfigState | undefined> {
364
if (!config?.config && !config?.workingDirectory) {
365
return undefined;
366
}
367
try {
368
const resolved = await provider.resolveSessionConfig({
369
provider: provider.id,
370
workingDirectory: config.workingDirectory,
371
config: config.config,
372
});
373
return { schema: resolved.schema, values: resolved.values };
374
} catch (error) {
375
this._logService.error(`[AgentService] Failed to resolve created session config for provider ${provider.id}`, error);
376
return config.config ? { schema: { type: 'object', properties: {} }, values: config.config } : undefined;
377
}
378
}
379
380
async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<ResolveSessionConfigResult> {
381
const providerId = params.provider ?? this._defaultProvider;
382
const provider = providerId ? this._providers.get(providerId) : undefined;
383
if (!provider) {
384
throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`);
385
}
386
return provider.resolveSessionConfig(params);
387
}
388
389
async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<SessionConfigCompletionsResult> {
390
const providerId = params.provider ?? this._defaultProvider;
391
const provider = providerId ? this._providers.get(providerId) : undefined;
392
if (!provider) {
393
throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`);
394
}
395
return provider.sessionConfigCompletions(params);
396
}
397
398
async disposeSession(session: URI): Promise<void> {
399
this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`);
400
const provider = this._findProviderForSession(session);
401
if (provider) {
402
await provider.disposeSession(session);
403
this._sessionToProvider.delete(session.toString());
404
}
405
// Remove all subagent sessions for this parent
406
this._sideEffects.removeSubagentSessions(session.toString());
407
this._stateManager.deleteSession(session.toString());
408
}
409
410
// ---- Protocol methods ---------------------------------------------------
411
412
async createTerminal(params: CreateTerminalParams): Promise<void> {
413
await this._terminalManager.createTerminal(params);
414
}
415
416
async disposeTerminal(terminal: URI): Promise<void> {
417
this._terminalManager.disposeTerminal(terminal.toString());
418
}
419
420
async subscribe(resource: URI): Promise<IStateSnapshot> {
421
this._logService.trace(`[AgentService] subscribe: ${resource.toString()}`);
422
const resourceStr = resource.toString();
423
424
// Check for terminal state
425
const terminalState = this._terminalManager.getTerminalState(resourceStr);
426
if (terminalState) {
427
return { resource: resourceStr, state: terminalState, fromSeq: this._stateManager.serverSeq };
428
}
429
430
let snapshot = this._stateManager.getSnapshot(resourceStr);
431
if (!snapshot) {
432
// Try subagent restore before regular session restore
433
const parsed = parseSubagentSessionUri(resourceStr);
434
if (parsed) {
435
await this._restoreSubagentSession(resourceStr, parsed.parentSession, parsed.toolCallId);
436
} else {
437
await this.restoreSession(resource);
438
}
439
snapshot = this._stateManager.getSnapshot(resourceStr);
440
}
441
if (!snapshot) {
442
throw new Error(`Cannot subscribe to unknown resource: ${resourceStr}`);
443
}
444
445
// Ensure git state has been computed for this session. When the snapshot
446
// already existed (e.g. seeded by list query, or restored earlier), the
447
// restore path that normally calls `_attachGitState` is skipped — so
448
// trigger it lazily here for the first subscriber. `_attachGitState`
449
// is async and updates `_meta.git` once ready, which clients see via
450
// the normal state-update stream.
451
const sessionState = this._stateManager.getSessionState(resourceStr);
452
if (sessionState && readSessionGitState(sessionState._meta) === undefined) {
453
const wd = sessionState.summary?.workingDirectory;
454
this._attachGitState(resource, wd ? URI.parse(wd) : undefined);
455
}
456
457
return snapshot;
458
}
459
460
unsubscribe(resource: URI): void {
461
this._logService.trace(`[AgentService] unsubscribe: ${resource.toString()}`);
462
// Server-side tracking of per-client subscriptions will be added
463
// in Phase 4 (multi-client). For now this is a no-op.
464
}
465
466
dispatchAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {
467
this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action);
468
469
const origin = { clientId, clientSeq };
470
this._stateManager.dispatchClientAction(action, origin);
471
if (action.type === ActionType.RootConfigChanged) {
472
this._configurationService.persistRootConfig();
473
}
474
this._sideEffects.handleAction(action);
475
}
476
477
async resourceList(uri: URI): Promise<ResourceListResult> {
478
let stat;
479
try {
480
stat = await this._fileService.resolve(uri);
481
} catch {
482
throw new ProtocolError(AhpErrorCodes.NotFound, `Directory not found: ${uri.toString()}`);
483
}
484
485
if (!stat.isDirectory) {
486
throw new ProtocolError(AhpErrorCodes.NotFound, `Not a directory: ${uri.toString()}`);
487
}
488
489
const entries: DirectoryEntry[] = (stat.children ?? []).map(child => ({
490
name: child.name,
491
type: child.isDirectory ? 'directory' : 'file',
492
}));
493
return { entries };
494
}
495
496
async restoreSession(session: URI): Promise<void> {
497
const sessionStr = session.toString();
498
499
// Already in state manager - nothing to do.
500
if (this._stateManager.getSessionState(sessionStr)) {
501
return;
502
}
503
504
const agent = this._findProviderForSession(session);
505
if (!agent) {
506
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `No agent for session: ${sessionStr}`);
507
}
508
509
// Verify the session actually exists on the backend to avoid
510
// creating phantom sessions for made-up URIs.
511
let allSessions;
512
try {
513
allSessions = await agent.listSessions();
514
} catch (err) {
515
if (err instanceof ProtocolError) {
516
throw err;
517
}
518
const message = err instanceof Error ? err.message : String(err);
519
throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to list sessions for ${sessionStr}: ${message}`);
520
}
521
const meta = allSessions.find(s => s.session.toString() === sessionStr);
522
if (!meta) {
523
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`);
524
}
525
526
let turns: readonly Turn[];
527
try {
528
turns = await agent.getSessionMessages(session);
529
} catch (err) {
530
if (err instanceof ProtocolError) {
531
throw err;
532
}
533
const message = err instanceof Error ? err.message : String(err);
534
throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to restore session ${sessionStr}: ${message}`);
535
}
536
537
// Check for persisted metadata in the session database
538
let title = meta.summary ?? 'Session';
539
let isRead: boolean | undefined;
540
let isArchived: boolean | undefined;
541
let diffs: ISessionFileDiff[] | undefined;
542
let persistedConfigValues: Record<string, string> | undefined;
543
const ref = this._sessionDataService.tryOpenDatabase?.(session);
544
if (ref) {
545
try {
546
const db = await ref;
547
if (db) {
548
try {
549
const m = await db.object.getMetadataObject({ customTitle: true, isRead: true, isArchived: true, isDone: true, diffs: true, configValues: true });
550
if (m.customTitle) {
551
title = m.customTitle;
552
}
553
if (m.isRead !== undefined) {
554
isRead = m.isRead === 'true';
555
}
556
if (m.isArchived !== undefined) {
557
isArchived = m.isArchived === 'true';
558
} else if (m.isDone !== undefined) {
559
isArchived = m.isDone === 'true';
560
}
561
if (m.diffs) {
562
try { diffs = JSON.parse(m.diffs); } catch { /* ignore malformed */ }
563
}
564
if (m.configValues) {
565
try {
566
persistedConfigValues = JSON.parse(m.configValues);
567
} catch (err) {
568
this._logService.warn(`[AgentService] Failed to parse persisted configValues for ${sessionStr}: ${toErrorMessage(err)}`);
569
}
570
}
571
} finally {
572
db.dispose();
573
}
574
}
575
} catch {
576
// Best-effort: fall back to agent-provided metadata
577
}
578
}
579
580
// Encode isRead/isArchived as status bitmask flags
581
let status: SessionStatus = SessionStatus.Idle;
582
if (isRead) {
583
status |= SessionStatus.IsRead;
584
}
585
if (isArchived) {
586
status |= SessionStatus.IsArchived;
587
}
588
589
const summary: SessionSummary = {
590
resource: sessionStr,
591
provider: agent.id,
592
title,
593
status,
594
createdAt: meta.startTime,
595
modifiedAt: meta.modifiedTime,
596
...(meta.project ? { project: { uri: meta.project.uri.toString(), displayName: meta.project.displayName } } : {}),
597
model: meta.model,
598
workingDirectory: meta.workingDirectory?.toString(),
599
diffs,
600
};
601
602
this._stateManager.restoreSession(summary, [...turns]);
603
604
// Restore persisted `_meta` (e.g. git state) onto the new session
605
// state. This dispatches a SessionMetaChanged action.
606
if (meta._meta) {
607
this._stateManager.setSessionMeta(sessionStr, meta._meta);
608
}
609
610
// Resolve the session config so clients (e.g. the running-session
611
// auto-approve picker) can render session-mutable properties for
612
// sessions that were not created in the current process lifetime.
613
// Overlay any values the user previously selected (persisted via
614
// `SessionConfigChanged`) on top of the provider's resolved defaults.
615
const restoredConfig = await this._resolveCreatedSessionConfig(agent, {
616
workingDirectory: meta.workingDirectory,
617
config: persistedConfigValues,
618
});
619
if (restoredConfig) {
620
const restoredState = this._stateManager.getSessionState(sessionStr);
621
if (restoredState) {
622
restoredState.config = restoredConfig;
623
}
624
}
625
626
this._logService.info(`[AgentService] Restored session ${sessionStr} with ${turns.length} turns`);
627
628
// Lazily compute git state for sessions with a working directory;
629
// attaches under `state._meta.git` once ready.
630
this._attachGitState(session, meta.workingDirectory);
631
}
632
633
async resourceRead(uri: URI): Promise<ResourceReadResult> {
634
// Handle session-db: URIs that reference file-edit content stored
635
// in a per-session SQLite database.
636
const dbFields = parseSessionDbUri(uri.toString());
637
if (dbFields) {
638
return this._fetchSessionDbContent(dbFields);
639
}
640
641
// Handle git-blob: URIs that reference file content at a specific
642
// git commit (the merge-base used as diff baseline). The URI
643
// encodes the session it belongs to so we can find the right
644
// working directory to run `git show` from.
645
const blobFields = parseGitBlobUri(uri.toString());
646
if (blobFields) {
647
return this._fetchGitBlobContent(blobFields);
648
}
649
650
try {
651
const content = await this._fileService.readFile(uri);
652
return {
653
data: content.value.toString(),
654
encoding: ContentEncoding.Utf8,
655
contentType: 'text/plain',
656
};
657
} catch (_e) {
658
throw new ProtocolError(AhpErrorCodes.NotFound, `Content not found: ${uri.toString()}`);
659
}
660
}
661
662
async resourceWrite(params: ResourceWriteParams): Promise<ResourceWriteResult> {
663
const fileUri = typeof params.uri === 'string' ? URI.parse(params.uri) : URI.revive(params.uri);
664
let content: VSBuffer;
665
if (params.encoding === ContentEncoding.Base64) {
666
content = decodeBase64(params.data);
667
} else {
668
content = VSBuffer.fromString(params.data);
669
}
670
try {
671
if (params.createOnly) {
672
await this._fileService.createFile(fileUri, content, { overwrite: false });
673
} else {
674
await this._fileService.writeFile(fileUri, content);
675
}
676
return {};
677
} catch (e) {
678
const code = toFileSystemProviderErrorCode(e as Error);
679
if (code === FileSystemProviderErrorCode.FileExists) {
680
throw new ProtocolError(AhpErrorCodes.AlreadyExists, `File already exists: ${fileUri.toString()}`);
681
}
682
if (code === FileSystemProviderErrorCode.NoPermissions) {
683
throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${fileUri.toString()}`);
684
}
685
throw new ProtocolError(AhpErrorCodes.NotFound, `Failed to write file: ${fileUri.toString()}`);
686
}
687
}
688
689
async resourceCopy(params: ResourceCopyParams): Promise<ResourceCopyResult> {
690
const source = URI.parse(params.source);
691
const destination = URI.parse(params.destination);
692
try {
693
await this._fileService.copy(source, destination, !params.failIfExists);
694
return {};
695
} catch (e) {
696
const code = toFileSystemProviderErrorCode(e as Error);
697
if (code === FileSystemProviderErrorCode.FileExists) {
698
throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Destination already exists: ${destination.toString()}`);
699
}
700
if (code === FileSystemProviderErrorCode.NoPermissions) {
701
throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${source.toString()}`);
702
}
703
throw new ProtocolError(AhpErrorCodes.NotFound, `Source not found: ${source.toString()}`);
704
}
705
}
706
707
async resourceDelete(params: ResourceDeleteParams): Promise<ResourceDeleteResult> {
708
const fileUri = URI.parse(params.uri);
709
try {
710
await this._fileService.del(fileUri, { recursive: params.recursive });
711
return {};
712
} catch (e) {
713
const code = toFileSystemProviderErrorCode(e as Error);
714
if (code === FileSystemProviderErrorCode.NoPermissions) {
715
throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${fileUri.toString()}`);
716
}
717
throw new ProtocolError(AhpErrorCodes.NotFound, `Resource not found: ${fileUri.toString()}`);
718
}
719
}
720
721
async resourceMove(params: ResourceMoveParams): Promise<ResourceMoveResult> {
722
const source = URI.parse(params.source);
723
const destination = URI.parse(params.destination);
724
try {
725
await this._fileService.move(source, destination, !params.failIfExists);
726
return {};
727
} catch (e) {
728
const code = toFileSystemProviderErrorCode(e as Error);
729
if (code === FileSystemProviderErrorCode.FileExists) {
730
throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Destination already exists: ${destination.toString()}`);
731
}
732
if (code === FileSystemProviderErrorCode.NoPermissions) {
733
throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${source.toString()}`);
734
}
735
throw new ProtocolError(AhpErrorCodes.NotFound, `Source not found: ${source.toString()}`);
736
}
737
}
738
739
async shutdown(): Promise<void> {
740
this._logService.info('AgentService: shutting down all providers...');
741
const promises: Promise<void>[] = [];
742
for (const provider of this._providers.values()) {
743
promises.push(provider.shutdown());
744
}
745
await Promise.all(promises);
746
this._sessionToProvider.clear();
747
}
748
749
// ---- helpers ------------------------------------------------------------
750
751
private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise<ResourceReadResult> {
752
const sessionUri = URI.parse(fields.sessionUri);
753
const ref = this._sessionDataService.openDatabase(sessionUri);
754
try {
755
const content = await ref.object.readFileEditContent(fields.toolCallId, fields.filePath);
756
if (!content) {
757
throw new ProtocolError(AhpErrorCodes.NotFound, `File edit not found: toolCallId=${fields.toolCallId}, filePath=${fields.filePath}`);
758
}
759
const bytes = fields.part === 'before' ? content.beforeContent : content.afterContent;
760
if (!bytes) {
761
throw new ProtocolError(AhpErrorCodes.NotFound, `No ${fields.part} content for: toolCallId=${fields.toolCallId}, filePath=${fields.filePath}`);
762
}
763
return {
764
data: new TextDecoder().decode(bytes),
765
encoding: ContentEncoding.Utf8,
766
contentType: 'text/plain',
767
};
768
} finally {
769
ref.dispose();
770
}
771
}
772
773
private async _fetchGitBlobContent(fields: IGitBlobUriFields): Promise<ResourceReadResult> {
774
if (!this._gitService) {
775
throw new ProtocolError(AhpErrorCodes.NotFound, `git service unavailable for: ${fields.repoRelativePath}`);
776
}
777
const workingDirectory = this._stateManager.getSessionState(fields.sessionUri)?.summary.workingDirectory;
778
if (!workingDirectory) {
779
throw new ProtocolError(AhpErrorCodes.NotFound, `Session has no working directory for git-blob URI: ${fields.sessionUri}`);
780
}
781
const blob = await this._gitService.showBlob(URI.parse(workingDirectory), fields.sha, fields.repoRelativePath);
782
if (!blob) {
783
throw new ProtocolError(AhpErrorCodes.NotFound, `git blob not found: ${fields.sha}:${fields.repoRelativePath}`);
784
}
785
return {
786
data: blob.toString(),
787
encoding: ContentEncoding.Utf8,
788
contentType: 'text/plain',
789
};
790
}
791
792
/**
793
* Restores a subagent session from its parent session's event history.
794
* Loads the parent's raw messages, filters for events belonging to
795
* the subagent (by `parentToolCallId`), and builds the child session's
796
* turns from those events.
797
*/
798
private async _restoreSubagentSession(subagentUri: string, parentSession: string, toolCallId: string): Promise<void> {
799
// Ensure the parent session is loaded first
800
const parentUri = URI.parse(parentSession);
801
if (!this._stateManager.getSessionState(parentSession)) {
802
try {
803
await this.restoreSession(parentUri);
804
} catch {
805
this._logService.warn(`[AgentService] Cannot restore parent session for subagent: ${parentSession}`);
806
return;
807
}
808
}
809
810
const parentState = this._stateManager.getSessionState(parentSession);
811
if (!parentState) {
812
return;
813
}
814
815
// Search completed turns and active turn for the subagent content metadata
816
const allTurns = [...parentState.turns];
817
if (parentState.activeTurn) {
818
allTurns.push(parentState.activeTurn as Turn);
819
}
820
821
let subagentContent: ToolResultSubagentContent | undefined;
822
for (const turn of allTurns) {
823
for (const part of turn.responseParts) {
824
if (part.kind === ResponsePartKind.ToolCall) {
825
const tc = part.toolCall;
826
// Check both completed and running tool calls — running
827
// tool calls receive subagent content via ContentChanged
828
const content = tc.status === ToolCallStatus.Completed
829
? tc.content
830
: (tc.status === ToolCallStatus.Running ? tc.content : undefined);
831
if (content) {
832
for (const c of content) {
833
if (c.type === ToolResultContentType.Subagent && c.resource === subagentUri) {
834
subagentContent = c;
835
break;
836
}
837
}
838
}
839
}
840
}
841
if (subagentContent) {
842
break;
843
}
844
}
845
846
// Load the subagent's turns from the agent (which knows how to
847
// extract them from the parent session's event log).
848
let childTurns: readonly Turn[] = [];
849
const agent = this._findProviderForSession(parentUri);
850
if (agent) {
851
try {
852
childTurns = await agent.getSessionMessages(URI.parse(subagentUri));
853
} catch (err) {
854
this._logService.warn(`[AgentService] Failed to load subagent turns for ${subagentUri}`, err);
855
}
856
}
857
858
// Use metadata from subagent content if available, otherwise synthesize
859
const title = subagentContent?.title ?? 'Subagent';
860
861
this._stateManager.restoreSession(
862
{
863
resource: subagentUri,
864
provider: 'subagent',
865
title,
866
status: SessionStatus.Idle,
867
createdAt: Date.now(),
868
modifiedAt: Date.now(),
869
...(parentState?.summary.project ? { project: parentState.summary.project } : {}),
870
},
871
[...childTurns],
872
);
873
this._logService.info(`[AgentService] Restored subagent session: ${subagentUri} with ${childTurns.length} turn(s)`);
874
}
875
876
private _findProviderForSession(session: URI | string): IAgent | undefined {
877
const key = typeof session === 'string' ? session : session.toString();
878
const providerId = this._sessionToProvider.get(key);
879
if (providerId) {
880
return this._providers.get(providerId);
881
}
882
const schemeProvider = AgentSession.provider(session);
883
if (schemeProvider) {
884
return this._providers.get(schemeProvider);
885
}
886
// Fallback: try the default provider (handles resumed sessions not yet tracked)
887
if (this._defaultProvider) {
888
return this._providers.get(this._defaultProvider);
889
}
890
return undefined;
891
}
892
893
/**
894
* Sets the agents observable to trigger model re-fetch and
895
* `root/agentsChanged` via the autorun in {@link AgentSideEffects}.
896
*/
897
private _updateAgents(): void {
898
this._agents.set([...this._providers.values()], undefined);
899
}
900
901
override dispose(): void {
902
for (const provider of this._providers.values()) {
903
provider.dispose();
904
}
905
this._providers.clear();
906
super.dispose();
907
}
908
}
909
910