Skip to content

Commit 8fe8366

Browse files
committed
agentHost: support checkpointing and forking
This enables restore/undo checkpoint for agent host sessions. It also does some initial work for forking although this is not yet fully implemented. The copilot SDK does not actually support these yet, so to do these we shut down the session, rewrite copilot SDK's disk storage, and start it back up again. It actually works. We'll need to make sure it works when we upgrade the SDK, but I don't expect it to break terribly, as the Copilot SDK folks must already be backwards-compatible to arbitrary old SDK versions that exist on the user's device, and we'd essentially just be an 'old SDK' with some dependency on internals. Of course that should all be swapped out when they eventually add proper support for it. I just flagged the specific scheme of agent host sessions thus far while developing, but will clean up prior to merging.
1 parent 9ee53b4 commit 8fe8366

24 files changed

Lines changed: 2024 additions & 63 deletions

File tree

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ export interface IAgentCreateSessionConfig {
101101
readonly model?: string;
102102
readonly session?: URI;
103103
readonly workingDirectory?: URI;
104+
/** Fork from an existing session at a specific turn index. */
105+
readonly fork?: { readonly session: URI; readonly turnIndex: number };
104106
}
105107

106108
/** Serializable attachment passed alongside a message to the agent host. */
@@ -364,6 +366,23 @@ export interface IAgent {
364366
*/
365367
authenticate(resource: string, token: string): Promise<boolean>;
366368

369+
/**
370+
* Truncate a session's history. If `turnIndex` is provided (0-based), keeps
371+
* turns up to and including that turn. If omitted, all turns are removed.
372+
* Optional — not all providers support truncation.
373+
*/
374+
truncateSession?(session: URI, turnIndex?: number): Promise<void>;
375+
376+
/**
377+
* Fork a session at a specific turn, creating a new session on disk
378+
* with the source session's history up to and including the specified turn.
379+
* Optional — not all providers support forking.
380+
*
381+
* @param turnIndex 0-based turn index to fork at.
382+
* @returns The new session's raw ID.
383+
*/
384+
forkSession?(sourceSession: URI, newSessionId: string, turnIndex: number): Promise<void>;
385+
367386
/** Gracefully shut down all sessions. */
368387
shutdown(): Promise<void>;
369388

src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
// Generated from types/actions.ts — do not edit
1010
// Run `npm run generate` to regenerate.
1111

12-
import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction, type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, type ISessionCustomizationsChangedAction, type ISessionCustomizationToggledAction } from './actions.js';
12+
import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction, type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, type ISessionCustomizationsChangedAction, type ISessionCustomizationToggledAction, type ISessionTruncatedAction } from './actions.js';
1313

1414

1515
// ─── Root vs Session Action Unions ───────────────────────────────────────────
@@ -48,6 +48,7 @@ export type ISessionAction =
4848
| ISessionQueuedMessagesReorderedAction
4949
| ISessionCustomizationsChangedAction
5050
| ISessionCustomizationToggledAction
51+
| ISessionTruncatedAction
5152
;
5253

5354
/** Union of session actions that clients may dispatch. */
@@ -65,6 +66,7 @@ export type IClientSessionAction =
6566
| ISessionPendingMessageRemovedAction
6667
| ISessionQueuedMessagesReorderedAction
6768
| ISessionCustomizationToggledAction
69+
| ISessionTruncatedAction
6870
;
6971

7072
/** Union of session actions that only the server may produce. */
@@ -119,4 +121,5 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boo
119121
[ActionType.SessionQueuedMessagesReordered]: true,
120122
[ActionType.SessionCustomizationsChanged]: false,
121123
[ActionType.SessionCustomizationToggled]: true,
124+
[ActionType.SessionTruncated]: true,
122125
};

src/vs/platform/agentHost/common/state/protocol/actions.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const enum ActionType {
4545
SessionQueuedMessagesReordered = 'session/queuedMessagesReordered',
4646
SessionCustomizationsChanged = 'session/customizationsChanged',
4747
SessionCustomizationToggled = 'session/customizationToggled',
48+
SessionTruncated = 'session/truncated',
4849
}
4950

5051
// ─── Action Envelope ─────────────────────────────────────────────────────────
@@ -562,6 +563,31 @@ export interface ISessionCustomizationToggledAction {
562563
enabled: boolean;
563564
}
564565

566+
// ─── Truncation ──────────────────────────────────────────────────────────────
567+
568+
/**
569+
* Truncates a session's history. If `turnId` is provided, all turns after that
570+
* turn are removed and the specified turn is kept. If `turnId` is omitted, all
571+
* turns are removed.
572+
*
573+
* If there is an active turn it is silently dropped and the session status
574+
* returns to `idle`.
575+
*
576+
* Common use-case: truncate old data then dispatch a new
577+
* `session/turnStarted` with an edited message.
578+
*
579+
* @category Session Actions
580+
* @version 1
581+
* @clientDispatchable
582+
*/
583+
export interface ISessionTruncatedAction {
584+
type: ActionType.SessionTruncated;
585+
/** Session URI */
586+
session: URI;
587+
/** Keep turns up to and including this turn. Omit to clear all turns. */
588+
turnId?: string;
589+
}
590+
565591
// ─── Pending Message Actions ─────────────────────────────────────────────────
566592

567593
/**
@@ -664,4 +690,5 @@ export type IStateAction =
664690
| ISessionPendingMessageRemovedAction
665691
| ISessionQueuedMessagesReorderedAction
666692
| ISessionCustomizationsChangedAction
667-
| ISessionCustomizationToggledAction;
693+
| ISessionCustomizationToggledAction
694+
| ISessionTruncatedAction;

src/vs/platform/agentHost/common/state/protocol/commands.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,20 @@ export interface ISubscribeResult {
163163
* { "jsonrpc": "2.0", "id": 2, "error": { "code": -32003, "message": "Session already exists" } }
164164
* ```
165165
*/
166+
/**
167+
* Identifies a source session and turn to fork from.
168+
*
169+
* When provided in `createSession`, the server populates the new session with
170+
* content from the source session up to and including the response of the
171+
* specified turn.
172+
*/
173+
export interface ISessionForkSource {
174+
/** URI of the existing session to fork from */
175+
session: URI;
176+
/** Turn ID in the source session; content up to and including this turn's response is copied */
177+
turnId: string;
178+
}
179+
166180
export interface ICreateSessionParams {
167181
/** Session URI (client-chosen, e.g. `copilot:/<uuid>`) */
168182
session: URI;
@@ -172,6 +186,11 @@ export interface ICreateSessionParams {
172186
model?: string;
173187
/** Working directory for the session */
174188
workingDirectory?: URI;
189+
/**
190+
* Fork from an existing session. The new session is populated with content
191+
* from the source session up to and including the specified turn's response.
192+
*/
193+
fork?: ISessionForkSource;
175194
}
176195

177196
// ─── disposeSession ──────────────────────────────────────────────────────────

src/vs/platform/agentHost/common/state/protocol/reducers.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,27 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log
485485
return { ...state, customizations: updated };
486486
}
487487

488+
// ── Truncation ────────────────────────────────────────────────────────
489+
490+
case ActionType.SessionTruncated: {
491+
let turns: typeof state.turns;
492+
if (action.turnId === undefined) {
493+
turns = [];
494+
} else {
495+
const idx = state.turns.findIndex(t => t.id === action.turnId);
496+
if (idx < 0) {
497+
return state;
498+
}
499+
turns = state.turns.slice(0, idx + 1);
500+
}
501+
return {
502+
...state,
503+
turns,
504+
activeTurn: undefined,
505+
summary: { ...state.summary, status: SessionStatus.Idle, modifiedAt: Date.now() },
506+
};
507+
}
508+
488509
// ── Pending Messages ──────────────────────────────────────────────────
489510

490511
case ActionType.SessionPendingMessageSet: {

src/vs/platform/agentHost/common/state/protocol/version/registry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: numbe
5252
[ActionType.SessionQueuedMessagesReordered]: 1,
5353
[ActionType.SessionCustomizationsChanged]: 1,
5454
[ActionType.SessionCustomizationToggled]: 1,
55+
[ActionType.SessionTruncated]: 1,
5556
};
5657

5758
/**

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

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -173,17 +173,41 @@ export class AgentService extends Disposable implements IAgentService {
173173
this._sessionToProvider.set(session.toString(), provider.id);
174174
this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`);
175175

176-
// Create state in the state manager
177-
const summary: ISessionSummary = {
178-
resource: session.toString(),
179-
provider: provider.id,
180-
title: 'New Session',
181-
status: SessionStatus.Idle,
182-
createdAt: Date.now(),
183-
modifiedAt: Date.now(),
184-
workingDirectory: config?.workingDirectory?.toString(),
185-
};
186-
this._stateManager.createSession(summary);
176+
// When forking, populate the new session's protocol state with
177+
// the source session's turns so the client sees the forked history.
178+
if (config?.fork) {
179+
const sourceState = this._stateManager.getSessionState(config.fork.session.toString());
180+
let sourceTurns: ITurn[] = [];
181+
if (sourceState) {
182+
const forkIdx = sourceState.turns.findIndex(t => t.id === config.fork!.turnId);
183+
if (forkIdx >= 0) {
184+
sourceTurns = sourceState.turns.slice(0, forkIdx + 1);
185+
}
186+
}
187+
188+
const summary: ISessionSummary = {
189+
resource: session.toString(),
190+
provider: provider.id,
191+
title: sourceState?.summary.title ?? 'Forked Session',
192+
status: SessionStatus.Idle,
193+
createdAt: Date.now(),
194+
modifiedAt: Date.now(),
195+
workingDirectory: config.workingDirectory?.toString(),
196+
};
197+
this._stateManager.restoreSession(summary, sourceTurns);
198+
} else {
199+
// Create empty state for new sessions
200+
const summary: ISessionSummary = {
201+
resource: session.toString(),
202+
provider: provider.id,
203+
title: 'New Session',
204+
status: SessionStatus.Idle,
205+
createdAt: Date.now(),
206+
modifiedAt: Date.now(),
207+
workingDirectory: config?.workingDirectory?.toString(),
208+
};
209+
this._stateManager.createSession(summary);
210+
}
187211
this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: session.toString() });
188212

189213
return session;

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,24 @@ export class AgentSideEffects extends Disposable {
252252
this._syncPendingMessages(action.session);
253253
break;
254254
}
255+
case ActionType.SessionTruncated: {
256+
const agent = this._options.getAgent(action.session);
257+
// Resolve the protocol turnId to a 0-based index using the
258+
// state manager's turn list. The reducer has already applied
259+
// the truncation, so we look at the pre-truncation state via
260+
// the turnId position.
261+
let turnIndex: number | undefined;
262+
if (action.turnId !== undefined) {
263+
const state = this._stateManager.getSessionState(action.session);
264+
// After the reducer, the turns array is already truncated.
265+
// The kept turns include the target, so its index = length - 1.
266+
turnIndex = state ? state.turns.length - 1 : undefined;
267+
}
268+
agent?.truncateSession?.(URI.parse(action.session), turnIndex).catch(err => {
269+
this._logService.error('[AgentSideEffects] truncateSession failed', err);
270+
});
271+
break;
272+
}
255273
}
256274
}
257275

0 commit comments

Comments
 (0)