@modelprofile.com/flexharness 9.3.1 → 9.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import {
4
4
  FlexHarnessCallbackOverflowError,
5
5
  FlexHarnessClosedError,
6
6
  FlexHarnessExternalError,
7
+ FlexHarnessImportedSessionError,
7
8
  FlexHarnessNotFoundError,
8
9
  FlexHarnessPermissionRejectedError,
9
10
  FlexHarnessPermissionStateError,
@@ -25,13 +26,17 @@ import {
25
26
  FLEX_REVERSION_REFERENCE_MAX_BYTES,
26
27
  FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES,
27
28
  FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES,
29
+ FLEX_SESSION_IMPORT_MAX_DEPTH,
28
30
  } from './interfaces.js';
29
31
  import type {
32
+ IFlexAgentContextCompactionOptions,
30
33
  IFlexAgentContextInvocation,
31
34
  IFlexAgentSessionPolicy,
32
35
  IFlexAffectedWorkspace,
33
36
  IFlexBackgroundExecution,
37
+ IFlexBeginSessionImportOptions,
34
38
  IFlexCallbackLimits,
39
+ IFlexCompactSessionOptions,
35
40
  IFlexCreateProjectTaskInput,
36
41
  IFlexCreateSessionOptions,
37
42
  IFlexDeleteSessionGenerationCohortInput,
@@ -43,6 +48,7 @@ import type {
43
48
  IFlexExecutionContextHandle,
44
49
  IFlexHarnessOptions,
45
50
  IFlexHarnessStores,
51
+ IFlexImportedRun,
46
52
  IFlexMessage,
47
53
  IFlexMessagePage,
48
54
  IFlexMessagePageOptions,
@@ -83,6 +89,10 @@ import type {
83
89
  IFlexSessionActivity,
84
90
  IFlexSessionGeneration,
85
91
  IFlexSessionGenerationCohortEntry,
92
+ IFlexSessionImportHandle,
93
+ IFlexSessionImportLimits,
94
+ IFlexSessionImportProgress,
95
+ IFlexSessionOrigin,
86
96
  IFlexSessionReversionGroup,
87
97
  IFlexSessionReversionInfo,
88
98
  IFlexSessionTombstone,
@@ -104,6 +114,7 @@ import type {
104
114
  TFlexAgentToolCallUpdateEvent,
105
115
  TFlexAgentToolCallFinishEvent,
106
116
  TFlexAgentToolSet,
117
+ TFlexBeginSessionImportResult,
107
118
  TFlexExternalErrorProjector,
108
119
  TFlexExternalErrorSource,
109
120
  TFlexHarnessEvent,
@@ -182,8 +193,17 @@ import {
182
193
  truncateUtf8,
183
194
  type IRunResultProjection,
184
195
  } from './utils.transfer.js';
196
+ import {
197
+ normalizeBeginSessionImportOptions,
198
+ normalizeImportedRunPage,
199
+ resolveSessionImportLimits,
200
+ sessionImportIdentity,
201
+ validateSessionImportHandle,
202
+ type INormalizedSessionImportOptions,
203
+ } from './utils.sessionimport.js';
185
204
  import { wrapToolSet } from './utils.tooloutput.js';
186
205
  import {
206
+ validateCompactSessionOptions,
187
207
  validateCreateProjectTaskInput,
188
208
  validateDeleteSessionGenerationCohortInput,
189
209
  validateIdentifier,
@@ -206,6 +226,11 @@ type TRunPhase =
206
226
  | 'finalizing'
207
227
  | 'promoting';
208
228
 
229
+ /** The invocation a compaction runs under; a manual compaction also carries its requested model hint. */
230
+ interface ICompactorInvocation<TScope> extends IFlexAgentContextInvocation<TScope> {
231
+ readonly modelHint?: string;
232
+ }
233
+
209
234
  interface IStoredSessionState {
210
235
  storageKey: string;
211
236
  compactorContext?: IFlexAgentContextInvocation<unknown>;
@@ -291,6 +316,25 @@ interface IOrphanedExecutionContextOwner {
291
316
  sessionId: string;
292
317
  }
293
318
 
319
+ /** A staged session import: its runs stay in memory until the import is committed. */
320
+ interface IStagedSessionImport {
321
+ importId: string;
322
+ scopeId: string;
323
+ state: IStorageState;
324
+ scope: unknown;
325
+ sessionId: string;
326
+ options: INormalizedSessionImportOptions;
327
+ identity: string;
328
+ runs: IFlexImportedRun[];
329
+ runIds: Set<string>;
330
+ messageIds: Set<string>;
331
+ messageCount: number;
332
+ bytes: number;
333
+ phase: 'staging' | 'committing';
334
+ /** Discards the import when it stays idle; cleared once the import commits or is released. */
335
+ idleTimer?: ReturnType<typeof setTimeout>;
336
+ }
337
+
294
338
  interface IStorageState {
295
339
  storageKey: string;
296
340
  compactorLifecycleController: AbortController;
@@ -302,6 +346,8 @@ interface IStorageState {
302
346
  sessionDeletions: Map<string, Promise<void>>;
303
347
  initializingSessions: Set<string>;
304
348
  sessionInitializations: Map<string, Promise<void>>;
349
+ /** Staged session imports by session id; their ids are also in `initializingSessions`. */
350
+ sessionImports: Map<string, IStagedSessionImport>;
305
351
  tombstones: Map<string, IFlexSessionTombstone>;
306
352
  tombstoneCleanups: Map<string, Promise<void>>;
307
353
  activeRuns: Map<string, IActiveRun>;
@@ -567,6 +613,7 @@ const resourceToolStemLength = 16;
567
613
  const repairCancellationMessage = 'The process stopped before this run completed.';
568
614
  const scopeRetirementMessage = 'The scope is being retired.';
569
615
  const slashCommandSessionUnavailableReason = 'Session must be idle with no queued prompts.';
616
+ const importedSessionUnavailableReason = 'Session is an imported conversation and is read-only.';
570
617
  const externalErrorFallback: IFlexErrorInfo = Object.freeze({
571
618
  name: 'FlexHarnessExternalError',
572
619
  message: 'The model operation failed.',
@@ -706,6 +753,7 @@ export class FlexHarness<TScope = unknown> {
706
753
  private readonly callbackLimits: Required<IFlexCallbackLimits>;
707
754
  private readonly promptQueueLimits: Required<IFlexPromptQueueLimits>;
708
755
  private readonly reversionLimits: Required<IFlexReversionLimits>;
756
+ private readonly sessionImportLimits: Required<IFlexSessionImportLimits>;
709
757
  private readonly reversionMaintenanceTimeoutMs: number;
710
758
  private readonly turnReversionProvider: IFlexHarnessOptions<TScope>['turnReversionProvider'];
711
759
  private readonly reversionPolicy: TFlexReversionPolicy;
@@ -735,8 +783,9 @@ export class FlexHarness<TScope = unknown> {
735
783
  private readonly projectManagementQueues = new Map<string, Promise<void>>();
736
784
  private readonly activeSlashCommandExecutions = new Map<string, IActiveSlashCommandExecution>();
737
785
  private readonly activeSlashCommandListings = new Set<IActiveSlashCommandListing>();
786
+ private readonly stagedSessionImports = new Map<string, IStagedSessionImport>();
738
787
  private readonly compactorInvocationContext = new plugins.AsyncLocalStorage<
739
- IFlexAgentContextInvocation<TScope>
788
+ ICompactorInvocation<TScope>
740
789
  >();
741
790
  private readonly slashCommandInvocationContext = new plugins.AsyncLocalStorage<
742
791
  ISlashCommandInvocationOwner
@@ -771,6 +820,7 @@ export class FlexHarness<TScope = unknown> {
771
820
  this.callbackLimits = resolveCallbackLimits(options.callbackLimits);
772
821
  this.promptQueueLimits = resolvePromptQueueLimits(options.promptQueueLimits);
773
822
  this.reversionLimits = resolveReversionLimits(options.reversionLimits);
823
+ this.sessionImportLimits = resolveSessionImportLimits(options.sessionImportLimits);
774
824
  this.reversionMaintenanceTimeoutMs = resolveReversionMaintenanceTimeoutMs(options);
775
825
  this.turnReversionProvider = options.turnReversionProvider;
776
826
  this.reversionPolicy = options.reversionPolicy ?? 'transcript-optional';
@@ -988,6 +1038,271 @@ export class FlexHarness<TScope = unknown> {
988
1038
  return result;
989
1039
  }
990
1040
 
1041
+ /**
1042
+ * Starts a staged import of a finished conversation into a new read-only session. The session
1043
+ * stays hidden until `commitSessionImport()`. A committed session with the same source, source
1044
+ * id and part is reported instead of being imported twice.
1045
+ */
1046
+ public async beginSessionImport(
1047
+ scopeId: string,
1048
+ options: IFlexBeginSessionImportOptions,
1049
+ ): Promise<TFlexBeginSessionImportResult> {
1050
+ this.assertOpen();
1051
+ const normalized = normalizeBeginSessionImportOptions(options);
1052
+ const { scope, state } = await this.resolveState(scopeId);
1053
+ await state.scopeQueue;
1054
+ this.assertOpen();
1055
+ this.assertStateAcceptingWork(state);
1056
+ const { sessionId } = normalized;
1057
+ const identity = sessionImportIdentity(normalized.origin);
1058
+ for (const staged of state.sessionImports.values()) {
1059
+ if (staged.sessionId === sessionId || staged.identity === identity) {
1060
+ throw new FlexHarnessSessionBusyError(staged.sessionId, 'is already being imported');
1061
+ }
1062
+ }
1063
+ for (const stored of state.sessions.values()) {
1064
+ const origin = stored.session.origin;
1065
+ if (origin === undefined || sessionImportIdentity(origin) !== identity) continue;
1066
+ const existing: TFlexBeginSessionImportResult = {
1067
+ status: origin.digest === normalized.origin.digest ? 'already_imported' : 'digest_mismatch',
1068
+ session: stored.session,
1069
+ };
1070
+ return publicSnapshot(existing);
1071
+ }
1072
+ if (
1073
+ state.sessions.has(sessionId)
1074
+ || state.tombstones.has(sessionId)
1075
+ || state.initializingSessions.has(sessionId)
1076
+ ) {
1077
+ throw new FlexHarnessValidationError(`Session "${sessionId}" already exists or is being deleted.`);
1078
+ }
1079
+ if (normalized.parent) this.requireImportParent(state, sessionId, normalized.parent);
1080
+ if (this.stagedSessionImports.size >= this.sessionImportLimits.maxOpenImports) {
1081
+ throw new FlexHarnessQueueFullError('FlexHarness has reached its open session import limit.');
1082
+ }
1083
+ const staged: IStagedSessionImport = {
1084
+ importId: `import_${plugins.crypto.randomUUID()}`,
1085
+ scopeId,
1086
+ state,
1087
+ scope: scope.scope,
1088
+ sessionId,
1089
+ options: normalized,
1090
+ identity,
1091
+ runs: [],
1092
+ runIds: new Set(),
1093
+ messageIds: new Set(),
1094
+ messageCount: 0,
1095
+ bytes: 0,
1096
+ phase: 'staging',
1097
+ };
1098
+ this.stagedSessionImports.set(staged.importId, staged);
1099
+ this.armStagedImportIdleTimer(staged);
1100
+ state.sessionImports.set(sessionId, staged);
1101
+ state.initializingSessions.add(sessionId);
1102
+ const started: TFlexBeginSessionImportResult = {
1103
+ status: 'started',
1104
+ handle: { importId: staged.importId, scopeId, sessionId },
1105
+ };
1106
+ return publicSnapshot(started);
1107
+ }
1108
+
1109
+ /** Stages one page of runs, in order, for a started session import. A rejected page stages nothing. */
1110
+ public async appendImportedRuns(
1111
+ handle: IFlexSessionImportHandle,
1112
+ runs: readonly IFlexImportedRun[],
1113
+ ): Promise<IFlexSessionImportProgress> {
1114
+ const staged = this.requireStagedSessionImport(handle);
1115
+ this.assertOpen();
1116
+ this.assertStateAcceptingWork(staged.state);
1117
+ const page = normalizeImportedRunPage(runs, staged.sessionId, this.sessionImportLimits.maxPageBytes);
1118
+ const runIds = new Set<string>();
1119
+ const messageIds = new Set<string>();
1120
+ for (const run of page.runs) {
1121
+ if (staged.runIds.has(run.user.runId) || runIds.has(run.user.runId)) {
1122
+ throw new FlexHarnessValidationError(`Imported run "${run.user.runId}" is not unique.`);
1123
+ }
1124
+ runIds.add(run.user.runId);
1125
+ for (const message of [run.user, run.assistant]) {
1126
+ if (staged.messageIds.has(message.messageId) || messageIds.has(message.messageId)) {
1127
+ throw new FlexHarnessValidationError(`Imported message "${message.messageId}" is not unique.`);
1128
+ }
1129
+ messageIds.add(message.messageId);
1130
+ }
1131
+ }
1132
+ const messageCount = staged.messageCount + page.runs.length * 2;
1133
+ if (messageCount > this.sessionImportLimits.maxMessagesPerSession) {
1134
+ throw new FlexHarnessValidationError(
1135
+ `An imported session holds at most ${this.sessionImportLimits.maxMessagesPerSession} messages.`,
1136
+ );
1137
+ }
1138
+ const bytes = staged.bytes + page.bytes;
1139
+ if (bytes > this.sessionImportLimits.maxSessionBytes) {
1140
+ throw new FlexHarnessValidationError(
1141
+ `An imported session holds at most ${this.sessionImportLimits.maxSessionBytes} message bytes.`,
1142
+ );
1143
+ }
1144
+ staged.runs.push(...page.runs);
1145
+ for (const runId of runIds) staged.runIds.add(runId);
1146
+ for (const messageId of messageIds) staged.messageIds.add(messageId);
1147
+ staged.messageCount = messageCount;
1148
+ staged.bytes = bytes;
1149
+ this.armStagedImportIdleTimer(staged);
1150
+ return publicSnapshot({ runs: staged.runs.length, messages: messageCount, bytes });
1151
+ }
1152
+
1153
+ /**
1154
+ * Persists the staged runs and publishes the session: archived, read-only and marked with its
1155
+ * `origin`. A failed commit removes everything it persisted before it rejects.
1156
+ */
1157
+ public async commitSessionImport(handle: IFlexSessionImportHandle): Promise<IFlexSession> {
1158
+ const staged = this.requireStagedSessionImport(handle);
1159
+ this.assertOpen();
1160
+ const { state, scopeId, sessionId, options } = staged;
1161
+ this.assertStateAcceptingWork(state);
1162
+ if (staged.runs.length === 0) {
1163
+ throw new FlexHarnessValidationError('A session import needs at least one run before it is committed.');
1164
+ }
1165
+ staged.phase = 'committing';
1166
+ this.clearStagedImportIdleTimer(staged);
1167
+ const scope = staged.scope as TScope;
1168
+ let resolveInitialization!: () => void;
1169
+ const initialization = new Promise<void>((resolve) => {
1170
+ resolveInitialization = resolve;
1171
+ });
1172
+ let initializationCompleted = false;
1173
+ const completeInitialization = () => {
1174
+ if (initializationCompleted) return;
1175
+ initializationCompleted = true;
1176
+ if (state.sessionInitializations.get(sessionId) === initialization) {
1177
+ state.sessionInitializations.delete(sessionId);
1178
+ }
1179
+ resolveInitialization();
1180
+ };
1181
+ state.sessionInitializations.set(sessionId, initialization);
1182
+ const importedAt = new Date().toISOString();
1183
+ const origin: IFlexSessionOrigin = {
1184
+ kind: 'import',
1185
+ ...cloneSerializable(options.origin),
1186
+ importedAt,
1187
+ };
1188
+ let metadata!: IFlexSession;
1189
+ let reserved = false;
1190
+ let loaded: IStoredSessionState | undefined;
1191
+ try {
1192
+ // A tombstone reserves the id while the projection is written, so a crash before the session
1193
+ // is published leaves only data that the next load or deletion cleans up.
1194
+ await this.mutateScope(state, () => {
1195
+ if (state.sessions.has(sessionId) || state.tombstones.has(sessionId)) {
1196
+ throw this.trustInternalError(new FlexHarnessValidationError(
1197
+ `Session "${sessionId}" already exists or is being deleted.`,
1198
+ ));
1199
+ }
1200
+ const parent = options.parent
1201
+ ? this.requireImportParent(state, sessionId, options.parent)
1202
+ : undefined;
1203
+ metadata = {
1204
+ scopeId,
1205
+ sessionId,
1206
+ sessionGenerationId: createSessionGenerationId(),
1207
+ sessionGenerationSequence: state.revision + 1,
1208
+ ...(options.title === undefined ? {} : { title: options.title }),
1209
+ createdAt: options.createdAt,
1210
+ updatedAt: options.updatedAt,
1211
+ archivedAt: importedAt,
1212
+ status: 'idle',
1213
+ activity: { status: 'idle' },
1214
+ ...(options.parent === undefined
1215
+ ? {}
1216
+ : {
1217
+ parentSessionId: options.parent.sessionId,
1218
+ parentRunId: options.parent.runId,
1219
+ parentToolCallId: options.parent.toolCallId,
1220
+ agent: options.parent.agent,
1221
+ }),
1222
+ depth: parent === undefined ? 0 : (parent.session.depth ?? 0) + 1,
1223
+ origin,
1224
+ };
1225
+ state.tombstones.set(sessionId, {
1226
+ sessionId,
1227
+ ...requireSessionGeneration(metadata),
1228
+ deletedAt: importedAt,
1229
+ rootSessionId: sessionId,
1230
+ depth: 0,
1231
+ });
1232
+ });
1233
+ reserved = true;
1234
+ await this.stores.projections.save(state.storageKey, sessionId, {
1235
+ schemaVersion: 3,
1236
+ revision: 1,
1237
+ messages: staged.runs.flatMap((run) => [run.user, run.assistant]),
1238
+ stagedTerminals: [],
1239
+ reversionSegments: [],
1240
+ revertCursor: 0,
1241
+ excludedRunIds: [],
1242
+ pendingReversionReleases: [],
1243
+ }, 0);
1244
+ const runtime = await this.loadSessionRuntime(state, metadata, scopeId, scope);
1245
+ loaded = runtime;
1246
+ await this.mutateScope(state, () => {
1247
+ if (
1248
+ state.sessions.has(sessionId)
1249
+ || state.tombstones.get(sessionId)?.sessionGenerationId !== metadata.sessionGenerationId
1250
+ ) {
1251
+ throw this.trustInternalError(new FlexHarnessAbortError(
1252
+ 'The session import lost its reservation before it was committed.',
1253
+ ));
1254
+ }
1255
+ if (options.parent) this.requireImportParent(state, sessionId, options.parent);
1256
+ state.tombstones.delete(sessionId);
1257
+ state.sessions.set(sessionId, runtime);
1258
+ });
1259
+ } catch (error) {
1260
+ const projected = this.projectOperationError(error, 'persistence', scopeId, sessionId, 'session-import');
1261
+ const errors: unknown[] = [projected];
1262
+ if (loaded && state.sessions.get(sessionId) !== loaded) {
1263
+ try {
1264
+ await this.closeStoredSession(loaded);
1265
+ } catch (closeError) {
1266
+ this.orphanedStoredSessions.add(loaded);
1267
+ errors.push(closeError);
1268
+ }
1269
+ }
1270
+ // A fenced namespace cannot tell whether its last save was persisted; its next load resolves
1271
+ // the reservation instead: a surviving tombstone is cleaned up, a published session is kept.
1272
+ if (reserved && state.tombstones.has(sessionId) && state.lifecycle !== 'fenced') {
1273
+ completeInitialization();
1274
+ try {
1275
+ await this.finishTombstoneCleanup(state, sessionId, scopeId, { scopeId, scope });
1276
+ } catch (cleanupError) {
1277
+ errors.push(this.projectOperationError(
1278
+ cleanupError,
1279
+ 'persistence',
1280
+ scopeId,
1281
+ sessionId,
1282
+ 'session-import-cleanup',
1283
+ ));
1284
+ }
1285
+ }
1286
+ throw combineErrors(errors);
1287
+ } finally {
1288
+ this.releaseStagedSessionImport(staged);
1289
+ completeInitialization();
1290
+ }
1291
+ const result = publicSnapshot(metadata);
1292
+ this.emitEvent(scopeId, metadata, { type: 'session.created', session: result });
1293
+ return result;
1294
+ }
1295
+
1296
+ /** Discards a staged import. Nothing of it was persisted; an unknown or finished handle is ignored. */
1297
+ public async abortSessionImport(handle: IFlexSessionImportHandle): Promise<void> {
1298
+ const staged = this.findStagedSessionImport(handle);
1299
+ if (!staged) return;
1300
+ if (staged.phase === 'committing') {
1301
+ throw new FlexHarnessSessionBusyError(staged.sessionId, 'is being committed');
1302
+ }
1303
+ this.releaseStagedSessionImport(staged);
1304
+ }
1305
+
991
1306
  public async getSession(scopeId: string, sessionId: string): Promise<IFlexSession> {
992
1307
  const { state } = await this.resolveState(scopeId);
993
1308
  await state.scopeQueue;
@@ -1060,7 +1375,7 @@ export class FlexHarness<TScope = unknown> {
1060
1375
  scopeId: string,
1061
1376
  sessionId: string,
1062
1377
  ): Promise<IFlexProjectStateResult> {
1063
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1378
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'read');
1064
1379
  const snapshot = await this.readProjectManagementState(state, stored);
1065
1380
  return this.projectStateResult(snapshot);
1066
1381
  }
@@ -1069,7 +1384,7 @@ export class FlexHarness<TScope = unknown> {
1069
1384
  scopeId: string,
1070
1385
  sessionId: string,
1071
1386
  ): Promise<IFlexProjectTasksResult> {
1072
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1387
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'read');
1073
1388
  const snapshot = await this.readProjectManagementState(state, stored);
1074
1389
  return this.projectTasksResult(snapshot, snapshot.tasks);
1075
1390
  }
@@ -1080,7 +1395,7 @@ export class FlexHarness<TScope = unknown> {
1080
1395
  input: IFlexCreateProjectTaskInput,
1081
1396
  ): Promise<IFlexProjectTaskResult> {
1082
1397
  validateCreateProjectTaskInput(input);
1083
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1398
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1084
1399
  return this.createProjectTaskInState(
1085
1400
  state,
1086
1401
  stored,
@@ -1095,7 +1410,7 @@ export class FlexHarness<TScope = unknown> {
1095
1410
  input: IFlexUpdateProjectTaskInput,
1096
1411
  ): Promise<IFlexProjectTaskResult> {
1097
1412
  validateUpdateProjectTaskInput(input);
1098
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1413
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1099
1414
  return this.updateProjectTaskInState(
1100
1415
  state,
1101
1416
  stored,
@@ -1110,7 +1425,7 @@ export class FlexHarness<TScope = unknown> {
1110
1425
  taskId: string,
1111
1426
  ): Promise<IFlexProjectTaskResult> {
1112
1427
  validateProjectTaskId(taskId);
1113
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1428
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1114
1429
  return this.deleteProjectTaskInState(
1115
1430
  state,
1116
1431
  stored,
@@ -1123,7 +1438,7 @@ export class FlexHarness<TScope = unknown> {
1123
1438
  scopeId: string,
1124
1439
  sessionId: string,
1125
1440
  ): Promise<IFlexProjectTasksResult> {
1126
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1441
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1127
1442
  return this.clearProjectTasksInState(
1128
1443
  state,
1129
1444
  stored,
@@ -1135,7 +1450,7 @@ export class FlexHarness<TScope = unknown> {
1135
1450
  scopeId: string,
1136
1451
  sessionId: string,
1137
1452
  ): Promise<IFlexProjectGoalResult> {
1138
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1453
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'read');
1139
1454
  const snapshot = await this.readProjectManagementState(state, stored);
1140
1455
  return this.projectGoalResult(snapshot);
1141
1456
  }
@@ -1151,7 +1466,7 @@ export class FlexHarness<TScope = unknown> {
1151
1466
  FLEX_PROJECT_MANAGEMENT_LIMITS.maxGoalBytes,
1152
1467
  true,
1153
1468
  );
1154
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1469
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1155
1470
  return this.setProjectGoalInState(
1156
1471
  state,
1157
1472
  stored,
@@ -1164,7 +1479,7 @@ export class FlexHarness<TScope = unknown> {
1164
1479
  scopeId: string,
1165
1480
  sessionId: string,
1166
1481
  ): Promise<IFlexProjectGoalResult> {
1167
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1482
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1168
1483
  return this.clearProjectGoalInState(
1169
1484
  state,
1170
1485
  stored,
@@ -1176,7 +1491,7 @@ export class FlexHarness<TScope = unknown> {
1176
1491
  scopeId: string,
1177
1492
  sessionId: string,
1178
1493
  ): Promise<IFlexProjectScratchpadResult> {
1179
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1494
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'read');
1180
1495
  const snapshot = await this.readProjectManagementState(state, stored);
1181
1496
  return this.projectScratchpadResult(snapshot);
1182
1497
  }
@@ -1191,7 +1506,7 @@ export class FlexHarness<TScope = unknown> {
1191
1506
  'project scratchpad',
1192
1507
  FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
1193
1508
  );
1194
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1509
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1195
1510
  return this.setProjectScratchpadInState(
1196
1511
  state,
1197
1512
  stored,
@@ -1210,7 +1525,7 @@ export class FlexHarness<TScope = unknown> {
1210
1525
  'project scratchpad append',
1211
1526
  FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
1212
1527
  );
1213
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1528
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1214
1529
  return this.appendProjectScratchpadInState(
1215
1530
  state,
1216
1531
  stored,
@@ -1223,7 +1538,7 @@ export class FlexHarness<TScope = unknown> {
1223
1538
  scopeId: string,
1224
1539
  sessionId: string,
1225
1540
  ): Promise<IFlexProjectScratchpadResult> {
1226
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1541
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1227
1542
  return this.clearProjectScratchpadInState(
1228
1543
  state,
1229
1544
  stored,
@@ -1287,6 +1602,7 @@ export class FlexHarness<TScope = unknown> {
1287
1602
  if (!tombstone || !matchesSessionGeneration(tombstone, validated.root)) {
1288
1603
  return { matched: false };
1289
1604
  }
1605
+ this.assertNoCommittingSessionImport(state, validated.root.sessionId);
1290
1606
  const rootSessionId = tombstone.rootSessionId ?? tombstone.sessionId;
1291
1607
  const descendantRoots = this.descendantTombstoneRoots(state, rootSessionId);
1292
1608
  this.assertAuthorizedSessionGenerationCohort(
@@ -1315,6 +1631,7 @@ export class FlexHarness<TScope = unknown> {
1315
1631
  this.assertNoAmbientSlashCommandTeardown({ storageKey: state.storageKey });
1316
1632
  validateIdentifier(sessionId, 'sessionId');
1317
1633
  await state.scopeQueue;
1634
+ this.assertNoCommittingSessionImport(state, sessionId);
1318
1635
  return this.startSessionDeletion(state, scopeId, scope.scope, sessionId);
1319
1636
  }
1320
1637
 
@@ -1636,12 +1953,18 @@ export class FlexHarness<TScope = unknown> {
1636
1953
  signal,
1637
1954
  );
1638
1955
  signal.throwIfAborted();
1639
- return this.createSlashCommandDescriptors(
1956
+ const descriptors = this.createSlashCommandDescriptors(
1640
1957
  state,
1641
1958
  stored,
1642
1959
  sessionAvailable,
1643
1960
  reversionIdleReason,
1644
1961
  );
1962
+ if (stored.session.origin === undefined) return descriptors;
1963
+ return publicSnapshot(descriptors.map((descriptor): IFlexSlashCommandDescriptor => ({
1964
+ ...descriptor,
1965
+ available: false,
1966
+ unavailableReason: importedSessionUnavailableReason,
1967
+ })));
1645
1968
  });
1646
1969
  }
1647
1970
 
@@ -1780,6 +2103,7 @@ export class FlexHarness<TScope = unknown> {
1780
2103
  this.assertOpen();
1781
2104
  this.assertStateAcceptingWork(state);
1782
2105
  const stored = this.requireSession(state, sessionId);
2106
+ this.assertSessionWritable(stored);
1783
2107
  const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
1784
2108
  const invocationOwner = this.slashCommandInvocationContext.getStore();
1785
2109
  if (invocationOwner?.sessionKey === sessionKey) {
@@ -1809,6 +2133,13 @@ export class FlexHarness<TScope = unknown> {
1809
2133
  if (parsed.arguments.length > 0) {
1810
2134
  throw new FlexHarnessValidationError('Slash command "/compact" does not accept arguments.');
1811
2135
  }
2136
+ const ignoredOption = (['system', 'maxSteps'] as const)
2137
+ .find((key) => promptOptions[key] !== undefined);
2138
+ if (ignoredOption) {
2139
+ throw new FlexHarnessValidationError(
2140
+ `Slash command "/compact" does not support the "${ignoredOption}" option.`,
2141
+ );
2142
+ }
1812
2143
  if (!this.agentSessionPolicy.contextCompactor) {
1813
2144
  throw new FlexHarnessSlashCommandUnavailableError(
1814
2145
  parsed.name,
@@ -1824,7 +2155,14 @@ export class FlexHarness<TScope = unknown> {
1824
2155
  options.signal,
1825
2156
  async (signal) => {
1826
2157
  await this.commitRevertedBranch(state, stored, scopeId, scope.scope);
1827
- await this.compactStoredSession(scopeId, scope.scope, state, stored, signal);
2158
+ await this.compactStoredSession(
2159
+ scopeId,
2160
+ scope.scope,
2161
+ state,
2162
+ stored,
2163
+ promptOptions.modelHint,
2164
+ signal,
2165
+ );
1828
2166
  return Object.freeze({ type: 'operation', name: parsed.name });
1829
2167
  },
1830
2168
  );
@@ -2361,6 +2699,7 @@ export class FlexHarness<TScope = unknown> {
2361
2699
  }
2362
2700
  const { scope, state } = await this.resolveState(scopeId);
2363
2701
  const stored = this.requireSession(state, sessionId);
2702
+ this.assertSessionWritable(stored);
2364
2703
  this.assertStateAcceptingWork(state);
2365
2704
  let canonical: plugins.TAgentToolExecutionReconciliationOptions;
2366
2705
  if (reconciliation.resolution === 'executed') {
@@ -2400,6 +2739,7 @@ export class FlexHarness<TScope = unknown> {
2400
2739
  this.validateRuntimeEvent(event);
2401
2740
  const { scope, state } = await this.resolveState(scopeId);
2402
2741
  const stored = this.requireSession(state, sessionId);
2742
+ this.assertSessionWritable(stored);
2403
2743
  this.assertStateAcceptingWork(state);
2404
2744
  try {
2405
2745
  await this.withCompactorContext(
@@ -2411,9 +2751,15 @@ export class FlexHarness<TScope = unknown> {
2411
2751
  }
2412
2752
  }
2413
2753
 
2414
- public async compactSession(scopeId: string, sessionId: string): Promise<void> {
2754
+ public async compactSession(
2755
+ scopeId: string,
2756
+ sessionId: string,
2757
+ options: IFlexCompactSessionOptions = {},
2758
+ ): Promise<void> {
2759
+ const { modelHint } = validateCompactSessionOptions(options);
2415
2760
  const { scope, state } = await this.resolveState(scopeId);
2416
2761
  const stored = this.requireSession(state, sessionId);
2762
+ this.assertSessionWritable(stored);
2417
2763
  const invocationOwner = this.slashCommandInvocationContext.getStore();
2418
2764
  if (invocationOwner?.sessionKey === this.slashCommandSessionKey(state.storageKey, sessionId)) {
2419
2765
  throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
@@ -2434,7 +2780,7 @@ export class FlexHarness<TScope = unknown> {
2434
2780
  undefined,
2435
2781
  async (signal) => {
2436
2782
  await this.commitRevertedBranch(state, stored, scopeId, scope.scope);
2437
- await this.compactStoredSession(scopeId, scope.scope, state, stored, signal);
2783
+ await this.compactStoredSession(scopeId, scope.scope, state, stored, modelHint, signal);
2438
2784
  },
2439
2785
  );
2440
2786
  }
@@ -2444,6 +2790,7 @@ export class FlexHarness<TScope = unknown> {
2444
2790
  scope: TScope,
2445
2791
  state: IStorageState,
2446
2792
  stored: IStoredSessionState,
2793
+ modelHint: string | undefined,
2447
2794
  signal?: AbortSignal,
2448
2795
  ): Promise<void> {
2449
2796
  try {
@@ -2453,6 +2800,7 @@ export class FlexHarness<TScope = unknown> {
2453
2800
  scope,
2454
2801
  state.storageKey,
2455
2802
  stored.session.sessionId,
2803
+ modelHint,
2456
2804
  ),
2457
2805
  () => stored.agentSession.compact(signal === undefined ? {} : { abort: signal }),
2458
2806
  );
@@ -2659,6 +3007,7 @@ export class FlexHarness<TScope = unknown> {
2659
3007
  signal?: AbortSignal,
2660
3008
  ): Promise<string> {
2661
3009
  const stored = this.requireSession(state, sessionId);
3010
+ this.assertSessionWritable(stored);
2662
3011
  this.assertStateAcceptingWork(state);
2663
3012
  const invocationOwner = this.slashCommandInvocationContext.getStore();
2664
3013
  if (invocationOwner?.sessionKey === this.slashCommandSessionKey(state.storageKey, sessionId)) {
@@ -3011,6 +3360,7 @@ export class FlexHarness<TScope = unknown> {
3011
3360
  }
3012
3361
  const { scope, state } = await this.resolveState(scopeId);
3013
3362
  const stored = this.requireSession(state, sessionId);
3363
+ this.assertSessionWritable(stored);
3014
3364
  this.assertStateAcceptingWork(state);
3015
3365
  this.requireSessionAvailableForSlashCommand(state, stored, 'archive');
3016
3366
  const compaction = compactionEventId === undefined
@@ -3210,6 +3560,7 @@ export class FlexHarness<TScope = unknown> {
3210
3560
  }
3211
3561
  this.assertStateAcceptingWork(state);
3212
3562
  const stored = this.requireSession(state, sessionId);
3563
+ this.assertSessionWritable(stored);
3213
3564
  const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
3214
3565
  const invocationOwner = this.slashCommandInvocationContext.getStore();
3215
3566
  if (invocationOwner?.sessionKey === sessionKey) {
@@ -4180,13 +4531,16 @@ export class FlexHarness<TScope = unknown> {
4180
4531
  private async resolveProjectManagementSession(
4181
4532
  scopeId: string,
4182
4533
  sessionId: string,
4534
+ access: 'read' | 'write',
4183
4535
  ): Promise<{ state: IStorageState; stored: IStoredSessionState }> {
4184
4536
  this.requireProjectManagementTools();
4185
4537
  const { state } = await this.resolveState(scopeId);
4186
4538
  await state.scopeQueue;
4187
4539
  this.assertOpen();
4188
4540
  this.assertStateAcceptingWork(state);
4189
- return { state, stored: this.requireSession(state, sessionId) };
4541
+ const stored = this.requireSession(state, sessionId);
4542
+ if (access === 'write') this.assertSessionWritable(stored);
4543
+ return { state, stored };
4190
4544
  }
4191
4545
 
4192
4546
  private assertProjectManagementSession(
@@ -7955,6 +8309,7 @@ export class FlexHarness<TScope = unknown> {
7955
8309
  sessionDeletions: new Map(),
7956
8310
  initializingSessions: new Set(),
7957
8311
  sessionInitializations: new Map(),
8312
+ sessionImports: new Map(),
7958
8313
  tombstones: new Map(snapshot.tombstones.map((entry) => [entry.sessionId, entry])),
7959
8314
  tombstoneCleanups: new Map(),
7960
8315
  activeRuns: new Map(),
@@ -8275,15 +8630,17 @@ export class FlexHarness<TScope = unknown> {
8275
8630
  const ambientContext = this.compactorInvocationContext.getStore();
8276
8631
  const invocationContext = ambientContext
8277
8632
  ? (this.deferredCompactorContexts.get(ambientContext) ?? ambientContext) as
8278
- IFlexAgentContextInvocation<TScope>
8633
+ ICompactorInvocation<TScope>
8279
8634
  : undefined;
8280
8635
  if (
8281
- !invocationContext
8636
+ !ambientContext
8637
+ || !invocationContext
8282
8638
  || invocationContext.storageKey !== state.storageKey
8283
8639
  || invocationContext.sessionId !== sessionId
8284
8640
  ) {
8285
8641
  throw new Error('Agent context compaction is missing its exact FlexHarness invocation context.');
8286
8642
  }
8643
+ const { modelHint, ...invocation } = invocationContext;
8287
8644
  const filteredEvents = this.filterReversionContextEvents(stored, events);
8288
8645
  const filteredMessages = hydrateAgentMessages(
8289
8646
  (this.agentSessionPolicy.contextBuilder
@@ -8293,8 +8650,9 @@ export class FlexHarness<TScope = unknown> {
8293
8650
  );
8294
8651
  return contextCompactor(filteredMessages, filteredEvents, {
8295
8652
  ...options,
8296
- ...invocationContext,
8297
- scope: invocationContext.scope as TScope,
8653
+ ...invocation,
8654
+ scope: invocation.scope as TScope,
8655
+ ...this.compactionModel(state, sessionId, ambientContext, options.reason, modelHint),
8298
8656
  abortSignal: options.abortSignal
8299
8657
  ? AbortSignal.any([
8300
8658
  options.abortSignal,
@@ -8365,6 +8723,10 @@ export class FlexHarness<TScope = unknown> {
8365
8723
  scopeId?: string,
8366
8724
  scope?: TScope,
8367
8725
  ): Promise<boolean> {
8726
+ if (stored.session.origin !== undefined) {
8727
+ this.assertImportedSessionState(stored);
8728
+ return false;
8729
+ }
8368
8730
  const beforeProjection = JSON.stringify({
8369
8731
  messages: stored.messages,
8370
8732
  stagedTerminals: stored.stagedTerminals,
@@ -9884,6 +10246,9 @@ export class FlexHarness<TScope = unknown> {
9884
10246
  return;
9885
10247
  }
9886
10248
  if (state.lifecycle !== 'fenced') state.lifecycle = 'retiring';
10249
+ for (const staged of [...state.sessionImports.values()]) {
10250
+ if (staged.phase === 'staging') this.releaseStagedSessionImport(staged);
10251
+ }
9887
10252
  const errors: unknown[] = [];
9888
10253
  const listingSettlement = this.abortSlashCommandListings(storageKey, reason);
9889
10254
  if (listingSettlement) await listingSettlement;
@@ -10016,6 +10381,7 @@ export class FlexHarness<TScope = unknown> {
10016
10381
  state.pendingPermissions.clear();
10017
10382
  state.initializingSessions.clear();
10018
10383
  state.sessionInitializations.clear();
10384
+ state.sessionImports.clear();
10019
10385
  }
10020
10386
 
10021
10387
  private async disposeInternal(): Promise<void> {
@@ -10260,8 +10626,42 @@ export class FlexHarness<TScope = unknown> {
10260
10626
  scope: TScope,
10261
10627
  storageKey: string,
10262
10628
  sessionId: string,
10263
- ): IFlexAgentContextInvocation<TScope> {
10264
- return Object.freeze({ scopeId, scope, storageKey, sessionId });
10629
+ modelHint?: string,
10630
+ ): ICompactorInvocation<TScope> {
10631
+ return Object.freeze({
10632
+ scopeId,
10633
+ scope,
10634
+ storageKey,
10635
+ sessionId,
10636
+ ...(modelHint === undefined ? {} : { modelHint }),
10637
+ });
10638
+ }
10639
+
10640
+ /**
10641
+ * The model the compacted work was dispatched on: the resolved model of the run whose context
10642
+ * overflowed, or the model hint a manual compaction was requested with. Retention states none.
10643
+ */
10644
+ private compactionModel(
10645
+ state: IStorageState,
10646
+ sessionId: string,
10647
+ ambientContext: IFlexAgentContextInvocation<TScope>,
10648
+ reason: IFlexAgentContextCompactionOptions['reason'],
10649
+ modelHint: string | undefined,
10650
+ ): Pick<IFlexAgentContextCompactionOptions<TScope>, 'resolvedModel' | 'modelHint'> {
10651
+ if (reason === 'manual') return modelHint === undefined ? {} : { modelHint };
10652
+ if (reason !== 'context-overflow') return {};
10653
+ const run = state.activeRuns.get(sessionId);
10654
+ if (
10655
+ !run
10656
+ || (ambientContext !== run.originCompactorContext && ambientContext !== run.deferredCompactorContext)
10657
+ ) {
10658
+ throw new Error('Context-overflow compaction is missing its exact FlexHarness run.');
10659
+ }
10660
+ const resolved = run.modelResolution;
10661
+ if (!resolved) throw new Error('Context-overflow compaction ran before its run resolved a model.');
10662
+ return {
10663
+ resolvedModel: Object.freeze({ ...resolved, identity: Object.freeze({ ...resolved.identity }) }),
10664
+ };
10265
10665
  }
10266
10666
 
10267
10667
  private withCompactorContext<TResult>(
@@ -10322,6 +10722,138 @@ export class FlexHarness<TScope = unknown> {
10322
10722
  return stored;
10323
10723
  }
10324
10724
 
10725
+ /** Refuses an operation that would change an imported session's history or model context. */
10726
+ private assertSessionWritable(stored: IStoredSessionState): void {
10727
+ if (stored.session.origin !== undefined) {
10728
+ throw this.trustInternalError(new FlexHarnessImportedSessionError(stored.session.sessionId));
10729
+ }
10730
+ }
10731
+
10732
+ /** An imported session never ran here, so it has no hidden stage and no reversion state. */
10733
+ private assertImportedSessionState(stored: IStoredSessionState): void {
10734
+ if (
10735
+ stored.stagedTerminals.length > 0
10736
+ || stored.reversionSegments.length > 0
10737
+ || stored.excludedRunIds.length > 0
10738
+ || stored.pendingReversion !== undefined
10739
+ || stored.pendingReversionReleases.length > 0
10740
+ || stored.messages.some((message) => message.status === 'streaming')
10741
+ ) {
10742
+ throw new FlexHarnessValidationError(
10743
+ `Imported session "${stored.session.sessionId}" has run state it cannot have.`,
10744
+ );
10745
+ }
10746
+ }
10747
+
10748
+ private assertNoCommittingSessionImport(state: IStorageState, sessionId: string): void {
10749
+ if (state.sessionImports.get(sessionId)?.phase === 'committing') {
10750
+ throw new FlexHarnessSessionBusyError(sessionId, 'is being imported');
10751
+ }
10752
+ }
10753
+
10754
+ private findStagedSessionImport(
10755
+ handle: IFlexSessionImportHandle,
10756
+ ): IStagedSessionImport | undefined {
10757
+ const validated = validateSessionImportHandle(handle);
10758
+ const staged = this.stagedSessionImports.get(validated.importId);
10759
+ if (
10760
+ staged
10761
+ && (staged.scopeId !== validated.scopeId || staged.sessionId !== validated.sessionId)
10762
+ ) {
10763
+ throw new FlexHarnessValidationError('The session import handle does not match its import.');
10764
+ }
10765
+ return staged;
10766
+ }
10767
+
10768
+ private requireStagedSessionImport(handle: IFlexSessionImportHandle): IStagedSessionImport {
10769
+ const staged = this.findStagedSessionImport(handle);
10770
+ if (!staged) throw new FlexHarnessNotFoundError('Session import', handle.importId);
10771
+ if (staged.phase !== 'staging') {
10772
+ throw new FlexHarnessSessionBusyError(staged.sessionId, 'is being committed');
10773
+ }
10774
+ return staged;
10775
+ }
10776
+
10777
+ private armStagedImportIdleTimer(staged: IStagedSessionImport): void {
10778
+ this.clearStagedImportIdleTimer(staged);
10779
+ const timer = setTimeout(() => {
10780
+ if (staged.idleTimer !== timer) return;
10781
+ delete staged.idleTimer;
10782
+ if (staged.phase === 'staging') this.releaseStagedSessionImport(staged);
10783
+ }, this.sessionImportLimits.stagedImportIdleTimeoutMs);
10784
+ timer.unref();
10785
+ staged.idleTimer = timer;
10786
+ }
10787
+
10788
+ private clearStagedImportIdleTimer(staged: IStagedSessionImport): void {
10789
+ if (staged.idleTimer === undefined) return;
10790
+ clearTimeout(staged.idleTimer);
10791
+ delete staged.idleTimer;
10792
+ }
10793
+
10794
+ private releaseStagedSessionImport(staged: IStagedSessionImport): void {
10795
+ this.clearStagedImportIdleTimer(staged);
10796
+ if (this.stagedSessionImports.get(staged.importId) === staged) {
10797
+ this.stagedSessionImports.delete(staged.importId);
10798
+ }
10799
+ if (staged.state.sessionImports.get(staged.sessionId) === staged) {
10800
+ staged.state.sessionImports.delete(staged.sessionId);
10801
+ staged.state.initializingSessions.delete(staged.sessionId);
10802
+ }
10803
+ staged.runs = [];
10804
+ staged.runIds.clear();
10805
+ staged.messageIds.clear();
10806
+ }
10807
+
10808
+ /**
10809
+ * An imported child session belongs to a committed imported parent whose run `parent.runId` holds
10810
+ * the tool call `parent.toolCallId` naming the child, and no other session claims that tool call.
10811
+ */
10812
+ private requireImportParent(
10813
+ state: IStorageState,
10814
+ sessionId: string,
10815
+ parent: NonNullable<INormalizedSessionImportOptions['parent']>,
10816
+ ): IStoredSessionState {
10817
+ const parentStored = state.initializingSessions.has(parent.sessionId)
10818
+ || state.tombstones.has(parent.sessionId)
10819
+ ? undefined
10820
+ : state.sessions.get(parent.sessionId);
10821
+ if (!parentStored?.session.origin) {
10822
+ throw this.trustInternalError(new FlexHarnessValidationError(
10823
+ `The parent of imported session "${sessionId}" must be a committed imported session.`,
10824
+ ));
10825
+ }
10826
+ if ((parentStored.session.depth ?? 0) + 1 > FLEX_SESSION_IMPORT_MAX_DEPTH) {
10827
+ throw this.trustInternalError(new FlexHarnessValidationError(
10828
+ `Imported sessions nest at most ${FLEX_SESSION_IMPORT_MAX_DEPTH} levels deep.`,
10829
+ ));
10830
+ }
10831
+ const referenced = parentStored.messages.some((message) =>
10832
+ message.role === 'assistant'
10833
+ && message.runId === parent.runId
10834
+ && message.parts.some((part) =>
10835
+ part.type === 'tool'
10836
+ && part.toolCallId === parent.toolCallId
10837
+ && part.childSessionId === sessionId));
10838
+ if (!referenced) {
10839
+ throw this.trustInternalError(new FlexHarnessValidationError(
10840
+ `Parent run "${parent.runId}" has no tool call "${parent.toolCallId}" whose childSessionId is "${sessionId}".`,
10841
+ ));
10842
+ }
10843
+ for (const stored of state.sessions.values()) {
10844
+ if (
10845
+ stored.session.parentSessionId === parent.sessionId
10846
+ && stored.session.parentRunId === parent.runId
10847
+ && stored.session.parentToolCallId === parent.toolCallId
10848
+ ) {
10849
+ throw this.trustInternalError(new FlexHarnessValidationError(
10850
+ `Session "${stored.session.sessionId}" already belongs to tool call "${parent.toolCallId}".`,
10851
+ ));
10852
+ }
10853
+ }
10854
+ return parentStored;
10855
+ }
10856
+
10325
10857
  private requireMessage(stored: IStoredSessionState, messageId: string): IFlexMessage {
10326
10858
  const message = this.visibleMessages(stored).find((entry) => entry.messageId === messageId);
10327
10859
  if (!message) throw new FlexHarnessNotFoundError('Message', messageId);