@modelprofile.com/flexharness 10.23.0 → 10.24.1

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.
Files changed (83) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.flexharness.admission.d.ts +17 -0
  3. package/dist_ts/classes.flexharness.admission.js +263 -0
  4. package/dist_ts/classes.flexharness.callbacks.d.ts +38 -0
  5. package/dist_ts/classes.flexharness.callbacks.js +313 -0
  6. package/dist_ts/classes.flexharness.compaction.d.ts +25 -0
  7. package/dist_ts/classes.flexharness.compaction.js +87 -0
  8. package/dist_ts/classes.flexharness.configurations.d.ts +11 -0
  9. package/dist_ts/classes.flexharness.configurations.js +137 -0
  10. package/dist_ts/classes.flexharness.core.d.ts +143 -0
  11. package/dist_ts/classes.flexharness.core.js +1538 -0
  12. package/dist_ts/classes.flexharness.d.ts +2 -437
  13. package/dist_ts/classes.flexharness.deletion.d.ts +31 -0
  14. package/dist_ts/classes.flexharness.deletion.js +396 -0
  15. package/dist_ts/classes.flexharness.imports.d.ts +24 -0
  16. package/dist_ts/classes.flexharness.imports.js +345 -0
  17. package/dist_ts/classes.flexharness.js +119 -9776
  18. package/dist_ts/classes.flexharness.lifecycle.d.ts +47 -0
  19. package/dist_ts/classes.flexharness.lifecycle.js +625 -0
  20. package/dist_ts/classes.flexharness.permissions.d.ts +19 -0
  21. package/dist_ts/classes.flexharness.permissions.js +450 -0
  22. package/dist_ts/classes.flexharness.projects.d.ts +55 -0
  23. package/dist_ts/classes.flexharness.projects.js +575 -0
  24. package/dist_ts/classes.flexharness.prompts.d.ts +44 -0
  25. package/dist_ts/classes.flexharness.prompts.js +659 -0
  26. package/dist_ts/classes.flexharness.providers.d.ts +18 -0
  27. package/dist_ts/classes.flexharness.providers.js +267 -0
  28. package/dist_ts/classes.flexharness.results.d.ts +26 -0
  29. package/dist_ts/classes.flexharness.results.js +89 -0
  30. package/dist_ts/classes.flexharness.reversions.d.ts +52 -0
  31. package/dist_ts/classes.flexharness.reversions.js +1306 -0
  32. package/dist_ts/classes.flexharness.runs.d.ts +63 -0
  33. package/dist_ts/classes.flexharness.runs.js +1058 -0
  34. package/dist_ts/classes.flexharness.sessions.d.ts +20 -0
  35. package/dist_ts/classes.flexharness.sessions.js +381 -0
  36. package/dist_ts/classes.flexharness.slashcommands.d.ts +40 -0
  37. package/dist_ts/classes.flexharness.slashcommands.js +458 -0
  38. package/dist_ts/classes.flexharness.subagents.d.ts +31 -0
  39. package/dist_ts/classes.flexharness.subagents.js +690 -0
  40. package/dist_ts/classes.flexharness.tooldispatch.d.ts +53 -0
  41. package/dist_ts/classes.flexharness.tooldispatch.js +418 -0
  42. package/dist_ts/interfaces.flexharness.d.ts +455 -0
  43. package/dist_ts/interfaces.flexharness.js +2 -0
  44. package/dist_ts/utils.flexharness.d.ts +19 -0
  45. package/dist_ts/utils.flexharness.js +83 -0
  46. package/dist_ts_compaction/plugins.d.ts +2 -2
  47. package/dist_ts_compaction/plugins.js +3 -3
  48. package/dist_ts_compaction/smartagent.compaction.js +6 -5
  49. package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +9 -0
  50. package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +13 -1
  51. package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +2 -2
  52. package/dist_ts_stores_nosqldb/classes.recordstore.js +3 -3
  53. package/package.json +3 -3
  54. package/readme.md +1 -1
  55. package/ts/00_commitinfo_data.ts +1 -1
  56. package/ts/classes.flexharness.admission.ts +331 -0
  57. package/ts/classes.flexharness.callbacks.ts +352 -0
  58. package/ts/classes.flexharness.compaction.ts +167 -0
  59. package/ts/classes.flexharness.configurations.ts +167 -0
  60. package/ts/classes.flexharness.core.ts +1830 -0
  61. package/ts/classes.flexharness.deletion.ts +616 -0
  62. package/ts/classes.flexharness.imports.ts +414 -0
  63. package/ts/classes.flexharness.lifecycle.ts +786 -0
  64. package/ts/classes.flexharness.permissions.ts +535 -0
  65. package/ts/classes.flexharness.projects.ts +1006 -0
  66. package/ts/classes.flexharness.prompts.ts +857 -0
  67. package/ts/classes.flexharness.providers.ts +322 -0
  68. package/ts/classes.flexharness.results.ts +98 -0
  69. package/ts/classes.flexharness.reversions.ts +1684 -0
  70. package/ts/classes.flexharness.runs.ts +1204 -0
  71. package/ts/classes.flexharness.sessions.ts +483 -0
  72. package/ts/classes.flexharness.slashcommands.ts +705 -0
  73. package/ts/classes.flexharness.subagents.ts +860 -0
  74. package/ts/classes.flexharness.tooldispatch.ts +475 -0
  75. package/ts/classes.flexharness.ts +147 -12567
  76. package/ts/interfaces.flexharness.ts +517 -0
  77. package/ts/readme.md +1 -1
  78. package/ts/utils.flexharness.ts +121 -0
  79. package/ts_compaction/plugins.ts +2 -2
  80. package/ts_compaction/smartagent.compaction.ts +5 -4
  81. package/ts_stores_nosqldb/classes.nosqldbstores.ts +13 -0
  82. package/ts_stores_nosqldb/classes.recordstore.ts +5 -5
  83. package/ts_stores_nosqldb/readme.md +7 -0
@@ -0,0 +1,1204 @@
1
+ import * as plugins from './plugins.js';
2
+ import {
3
+ FlexHarnessAbortError,
4
+ FlexHarnessExternalError,
5
+ FlexHarnessNotFoundError,
6
+ FlexHarnessRunError,
7
+ FlexHarnessStepLimitError,
8
+ FlexHarnessValidationError,
9
+ errorToInfo,
10
+ } from './errors.js';
11
+ import {
12
+ FLEX_PROJECT_MANAGEMENT_LIMITS,
13
+ FLEX_RESULT_METHOD,
14
+ type IFlexAgentContextInvocation,
15
+ type IFlexMessage,
16
+ type IFlexPermissionRequestInput,
17
+ type IFlexPromptOptions,
18
+ type IFlexPromptResult,
19
+ type IFlexResolvedModel,
20
+ type IFlexRunFinishedEvent,
21
+ type IFlexSession,
22
+ type IFlexTerminalProjection,
23
+ type IFlexToolHandle,
24
+ type IFlexToolProviderContext,
25
+ type TFlexAgentToolSet,
26
+ type TFlexMessagePart,
27
+ } from './interfaces.js';
28
+ import { FlexToolCatalog, assertNoCatalogCollisions } from './classes.toolcatalog.js';
29
+ import { FlexCodeSandbox } from './classes.codesandbox.js';
30
+ import { cloneSerializable } from './utils.json.js';
31
+ import type { INormalizedFlexPrompt } from './utils.prompt.js';
32
+ import {
33
+ describeAttachment,
34
+ maxTransferMetadataBytes,
35
+ normalizeResolvedModel,
36
+ normalizeRunResult,
37
+ publicSnapshot,
38
+ truncateUtf8,
39
+ type IRunResultProjection,
40
+ } from './utils.transfer.js';
41
+ import { wrapToolSet } from './utils.tooloutput.js';
42
+ import { validateUtf8String } from './utils.validation.js';
43
+ import type {
44
+ TCanonicalOutcome,
45
+ IStoredSessionState,
46
+ IStorageState,
47
+ IActiveRun,
48
+ IRunSteering,
49
+ IRunSteeringOutcome,
50
+ IQueuedPrompt,
51
+ IResolverOutcome,
52
+ } from './interfaces.flexharness.js';
53
+ import {
54
+ externalErrorFallback,
55
+ requireSessionGeneration,
56
+ combineErrors,
57
+ terminalActivityError,
58
+ } from './utils.flexharness.js';
59
+ import type { FlexHarnessCore } from './classes.flexharness.core.js';
60
+
61
+ /** The run execution engine: generations, terminal outcomes, steering and cancellation. */
62
+ export class FlexHarnessRuns<TScope> {
63
+ constructor(private readonly core: FlexHarnessCore<TScope>) {}
64
+
65
+ public async abort(scopeId: string, sessionId: string): Promise<boolean> {
66
+ const { scope, state } = await this.core.resolveState(scopeId);
67
+ const run = state.activeRuns.get(sessionId);
68
+ if (!run || run.phase === 'finalizing' || run.phase === 'promoting') return false;
69
+ this.cancelRun(
70
+ run,
71
+ this.core.trustInternalError(new FlexHarnessAbortError()),
72
+ this.core.compaction.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
73
+ );
74
+ return true;
75
+ }
76
+
77
+ public createActiveRun(queued: IQueuedPrompt): IActiveRun {
78
+ const steering: IRunSteering = {
79
+ input: new plugins.AgentPendingInputQueue(),
80
+ stored: queued.stored,
81
+ accepted: new Map(),
82
+ applied: new Set(),
83
+ };
84
+ queued.steering = steering;
85
+ return {
86
+ steering,
87
+ stop: new AbortController(),
88
+ subagents: this.core.subagents,
89
+ state: queued.state,
90
+ stored: queued.stored,
91
+ originCompactorContext: this.core.compaction.createCompactorContext(
92
+ queued.scopeId,
93
+ queued.scope as TScope,
94
+ queued.state.storageKey,
95
+ queued.sessionId,
96
+ ),
97
+ scopeId: queued.scopeId,
98
+ scope: queued.scope,
99
+ sessionId: queued.sessionId,
100
+ queueId: queued.queueId,
101
+ runId: queued.runId!,
102
+ userMessageId: plugins.crypto.randomUUID(),
103
+ assistantMessageId: plugins.crypto.randomUUID(),
104
+ controller: new AbortController(),
105
+ ...(queued.scheduleKey === undefined
106
+ ? {}
107
+ : { scheduleKey: queued.scheduleKey, debounceMs: queued.debounceMs }),
108
+ callbackEventCount: 0,
109
+ callbackOutputBytes: 0,
110
+ callbackParts: [],
111
+ partTextUtf8Bytes: new Map(),
112
+ callbacksClosed: false,
113
+ finishedEventEmitted: false,
114
+ usage: {
115
+ inputTokens: 0,
116
+ outputTokens: 0,
117
+ totalTokens: 0,
118
+ cacheReadTokens: 0,
119
+ cacheWriteTokens: 0,
120
+ },
121
+ reasoningPartIds: new Map(),
122
+ toolPartIds: new Map(),
123
+ trustedToolExecutionErrors: new Map(),
124
+ unconfirmedToolCalls: new Map(),
125
+ readCalls: new Map(),
126
+ deduplicatedToolCalls: new Map(),
127
+ subagentCallCount: 0,
128
+ subagentSessionIds: new Set(),
129
+ backgroundSubagents: new Set(),
130
+ pendingPermissionIds: new Set(),
131
+ phase: 'admitting',
132
+ completion: queued.completion,
133
+ };
134
+ }
135
+
136
+ public async executeRun(
137
+ run: IActiveRun,
138
+ generation: Promise<plugins.IAgentGenerateResult>,
139
+ ): Promise<IFlexPromptResult> {
140
+ let generated = false;
141
+ try {
142
+ // The transactional generation settles only after its model calls are reported, so
143
+ // `run.usage` is complete once it settles, also for a cancelled run.
144
+ let rawResult: plugins.IAgentGenerateResult;
145
+ try {
146
+ rawResult = await generation;
147
+ generated = true;
148
+ } finally {
149
+ await this.core.callbacks.settleMetricsOperation(run.state, run.stored, run.metricsOperation!);
150
+ }
151
+ if (rawResult.terminationReason === 'step-limit') {
152
+ throw this.core.trustInternalError(new FlexHarnessStepLimitError(rawResult.steps));
153
+ }
154
+ // Background tools do not block inference, but the owning turn joins its admitted children
155
+ // before releasing its authority or committing its terminal projection.
156
+ await Promise.all([...run.backgroundSubagents]);
157
+ if (run.callbackError) throw run.callbackError;
158
+ if (run.controller.signal.aborted) throw run.controller.signal.reason;
159
+ const result = normalizeRunResult(rawResult);
160
+ if (!run.modelResolution) throw new FlexHarnessValidationError('Generation completed without a resolved model.');
161
+ await this.core.reversions.finalizeRunReversion(run, 'completed');
162
+ await this.core.admission.closeRunDelegatedRunAdmission(run);
163
+ const terminal = this.buildTerminal(run, 'completed', result);
164
+ try {
165
+ await this.core.mutateProjection(run.state, run.stored, () => {
166
+ this.replaceStagedTerminal(run.stored, terminal);
167
+ }, true);
168
+ } catch (error) {
169
+ throw this.core.projectExternalError(run, error, 'persistence');
170
+ }
171
+ if (run.controller.signal.aborted) throw run.controller.signal.reason;
172
+ run.phase = 'finalizing';
173
+ try {
174
+ await this.core.compaction.withRunCompactorContext(
175
+ run,
176
+ () => run.stored.agentSession.finalizeGeneration(run.transaction!, 'accepted'),
177
+ );
178
+ } catch (error) {
179
+ const projected = this.core.projectExternalError(run, error, 'agentSession');
180
+ await this.core.lifecycle.fenceNamespace(run.state, run, projected);
181
+ throw projected;
182
+ }
183
+ run.steering.keptInContext = true;
184
+ this.core.reversions.refreshAutomaticArchiveHorizon(run.stored);
185
+ run.phase = 'promoting';
186
+ const closePublication = this.core.openPublication(run.state, run.sessionId);
187
+ try {
188
+ try {
189
+ await this.promoteCompletedTerminal(run, terminal);
190
+ } catch (error) {
191
+ const projected = this.core.projectExternalError(run, error, 'persistence');
192
+ await this.core.lifecycle.fenceNamespace(run.state, run, projected);
193
+ throw projected;
194
+ }
195
+ this.emitTerminalProjection(run, terminal);
196
+ this.emitFinalRunEvent(run, terminal.assistantMessage, undefined, false);
197
+ } finally {
198
+ closePublication();
199
+ }
200
+ await this.core.reversions.drainReversionReleases(
201
+ run.state,
202
+ run.stored,
203
+ run.scopeId,
204
+ run.scope as TScope,
205
+ false,
206
+ );
207
+ return {
208
+ runId: run.runId,
209
+ sessionId: run.sessionId,
210
+ userMessage: publicSnapshot(terminal.userMessage),
211
+ assistantMessage: publicSnapshot(terminal.assistantMessage),
212
+ model: publicSnapshot(terminal.model!),
213
+ usage: publicSnapshot(terminal.usage!),
214
+ finishReason: terminal.finishReason!,
215
+ steps: terminal.steps!,
216
+ unappliedSteerIds: [...this.closeRunSteering(run.steering).unappliedSteerIds],
217
+ };
218
+ } catch (error) {
219
+ // A failed parent must cancel its admitted children before joining them. Otherwise a
220
+ // child waiting for permission or model I/O can keep a failed parent alive indefinitely.
221
+ if (run.backgroundSubagents.size > 0 && !run.controller.signal.aborted) run.controller.abort(error);
222
+ await Promise.allSettled([...run.backgroundSubagents]);
223
+ try {
224
+ if (run.state.lifecycle === 'fenced') {
225
+ await this.core.lifecycle.fenceNamespace(run.state, run, error);
226
+ throw error;
227
+ }
228
+ if (run.state.lifecycle === 'retired') throw error;
229
+ return await this.finishFailedRun(run, error, generated);
230
+ } catch (finalError) {
231
+ this.emitUnpublishedRunFinish(run, finalError, generated);
232
+ throw finalError;
233
+ }
234
+ } finally {
235
+ run.callbacksClosed = true;
236
+ this.core.permissions.rejectRunPermissions(
237
+ run.state,
238
+ run,
239
+ run.ownerCancellation ?? new FlexHarnessAbortError('The run ended before permission was resolved.'),
240
+ );
241
+ if (run.state.activeRuns.get(run.sessionId) === run) run.state.activeRuns.delete(run.sessionId);
242
+ }
243
+ }
244
+
245
+ private async finishFailedRun(
246
+ run: IActiveRun,
247
+ error: unknown,
248
+ generated: boolean,
249
+ ): Promise<never> {
250
+ run.callbacksClosed = true;
251
+ run.phase = 'closing';
252
+ const cancelled = run.ownerCancellation !== undefined && run.internalFailure === undefined;
253
+ const safeError = cancelled
254
+ ? run.ownerCancellation!
255
+ : this.core.projectExternalError(run, error, generated ? 'persistence' : 'agentSession');
256
+ const errors: unknown[] = [safeError];
257
+ let reversionFailed = false;
258
+ try {
259
+ await this.core.reversions.finalizeRunReversion(run, cancelled ? 'cancelled' : 'failed');
260
+ } catch (reversionError) {
261
+ errors.push(reversionError);
262
+ reversionFailed = true;
263
+ }
264
+ try {
265
+ await this.core.admission.closeRunDelegatedRunAdmission(run);
266
+ } catch (closeError) {
267
+ if (!errors.includes(closeError)) errors.push(closeError);
268
+ }
269
+ if (reversionFailed) {
270
+ const combined = combineErrors(errors);
271
+ await this.core.lifecycle.fenceNamespace(run.state, run, combined);
272
+ throw combined;
273
+ }
274
+ if (generated && !this.core.isTombstoned(run)) {
275
+ const failedStage = this.buildTerminal(run, cancelled ? 'cancelled' : 'failed', undefined, safeError);
276
+ try {
277
+ await this.core.mutateProjection(run.state, run.stored, () => {
278
+ this.replaceStagedTerminal(run.stored, failedStage);
279
+ }, true);
280
+ } catch (stageError) {
281
+ errors.push(this.core.projectExternalError(run, stageError, 'persistence'));
282
+ }
283
+ }
284
+
285
+ run.phase = 'finalizing';
286
+ const desiredOutcome: TCanonicalOutcome = cancelled || !generated ? 'interrupted' : 'rejected';
287
+ try {
288
+ await this.core.compaction.withRunCompactorContext(
289
+ run,
290
+ () => run.stored.agentSession.finalizeGeneration(
291
+ run.transaction!,
292
+ desiredOutcome,
293
+ { stopped: desiredOutcome === 'interrupted' && cancelled },
294
+ ),
295
+ );
296
+ } catch (finalizationError) {
297
+ errors.push(this.core.projectExternalError(run, finalizationError, 'agentSession'));
298
+ }
299
+ let outcome: TCanonicalOutcome | undefined;
300
+ try {
301
+ const snapshot = await run.stored.eventStore.load(run.stored.session.sessionId);
302
+ const events = snapshot?.events ?? [];
303
+ outcome = this.core.canonicalOutcomes(events).get(run.runId);
304
+ // A generation without a durable outcome is interrupted by the next load, which leaves it
305
+ // out of the model context.
306
+ run.steering.keptInContext = outcome !== undefined
307
+ && plugins.isGenerationInModelContext(events, run.runId);
308
+ this.core.reversions.refreshAutomaticArchiveHorizon(run.stored);
309
+ } catch (outcomeError) {
310
+ errors.push(this.core.projectExternalError(run, outcomeError, 'persistence'));
311
+ }
312
+ if (!outcome) {
313
+ const combined = combineErrors(errors);
314
+ await this.core.lifecycle.fenceNamespace(run.state, run, combined);
315
+ throw combined;
316
+ }
317
+ if (outcome === 'accepted') {
318
+ const combined = combineErrors(errors);
319
+ await this.core.lifecycle.fenceNamespace(run.state, run, combined);
320
+ throw combined;
321
+ }
322
+ const publicStatus = outcome === 'rejected' || !cancelled ? 'failed' : 'cancelled';
323
+ const terminalError = publicStatus === 'cancelled'
324
+ ? run.ownerCancellation ?? this.core.trustInternalError(new FlexHarnessAbortError())
325
+ : safeError;
326
+ const terminal = this.buildTerminal(run, publicStatus, undefined, terminalError);
327
+ if (!this.core.isTombstoned(run)) {
328
+ const closePublication = this.core.openPublication(run.state, run.sessionId);
329
+ try {
330
+ try {
331
+ await this.publishFailedTerminal(run, terminal);
332
+ } catch (persistenceError) {
333
+ errors.push(this.core.projectExternalError(run, persistenceError, 'persistence'));
334
+ const combined = combineErrors(errors);
335
+ await this.core.lifecycle.fenceNamespace(run.state, run, combined);
336
+ throw combined;
337
+ }
338
+ this.emitTerminalProjection(run, terminal);
339
+ this.emitFinalRunEvent(run, terminal.assistantMessage, terminalError, publicStatus === 'cancelled');
340
+ } finally {
341
+ closePublication();
342
+ }
343
+ }
344
+ await this.core.reversions.drainReversionReleases(
345
+ run.state,
346
+ run.stored,
347
+ run.scopeId,
348
+ run.scope as TScope,
349
+ false,
350
+ );
351
+ throw errors.length > 1 ? combineErrors(errors) : terminalError;
352
+ }
353
+
354
+ public async prepareGeneration(
355
+ run: IActiveRun,
356
+ options: IFlexPromptOptions,
357
+ signal: AbortSignal,
358
+ ): Promise<plugins.IAgentGenerationLease> {
359
+ signal.throwIfAborted();
360
+ const configuration = await this.core.configurations.resolveSessionConfiguration(
361
+ run.scopeId,
362
+ run.scope as TScope,
363
+ run.state,
364
+ run.stored,
365
+ signal,
366
+ );
367
+ run.subagents = configuration.subagents;
368
+ run.allowedTools = configuration.allowedTools;
369
+ run.permissionPolicy = configuration.permissionPolicy as IActiveRun['permissionPolicy'];
370
+ const agent = run.stored.session.agent;
371
+ if (agent !== undefined) {
372
+ const definition = configuration.subagents.get(agent);
373
+ if (!definition) throw new FlexHarnessValidationError(`Subagent "${agent}" is disabled.`);
374
+ run.agentAllowedTools = definition.allowedTools;
375
+ }
376
+ await this.core.reversions.prepareRunReversion(run, signal);
377
+ run.phase = 'running';
378
+ const queued = run.stored.outstandingPromptsById.get(run.queueId);
379
+ if (queued && (queued.status === 'starting' || queued.status === 'scheduled')) {
380
+ queued.status = 'running';
381
+ this.core.prompts.emitPromptQueueEvent(queued, 'prompt.running');
382
+ }
383
+ const resolverRelationship = {
384
+ ...(run.stored.session.parentSessionId === undefined
385
+ ? {}
386
+ : { parentSessionId: run.stored.session.parentSessionId }),
387
+ ...(run.stored.session.agent === undefined ? {} : { agent: run.stored.session.agent }),
388
+ };
389
+ const modelOutcome = Promise.resolve()
390
+ .then(() => this.core.modelResolver.resolveModel(Object.freeze({
391
+ scopeId: run.scopeId,
392
+ scope: run.scope as TScope,
393
+ sessionId: run.sessionId,
394
+ ...requireSessionGeneration(run.stored.session),
395
+ runId: run.runId,
396
+ ...(options.modelHint ? { modelHint: options.modelHint } : {}),
397
+ ...resolverRelationship,
398
+ signal,
399
+ })))
400
+ .then(
401
+ (value): IResolverOutcome<IFlexResolvedModel> => ({ source: 'model', success: true, value }),
402
+ (error): IResolverOutcome<IFlexResolvedModel> => ({ source: 'model', success: false, error }),
403
+ );
404
+ const toolProviderContext: Readonly<IFlexToolProviderContext<TScope>> = Object.freeze({
405
+ scopeId: run.scopeId,
406
+ scope: run.scope as TScope,
407
+ sessionId: run.sessionId,
408
+ ...requireSessionGeneration(run.stored.session),
409
+ runId: run.runId,
410
+ ...resolverRelationship,
411
+ signal,
412
+ requestPermission: async (request: IFlexPermissionRequestInput) => {
413
+ // A call that started early asks once the model's response confirmed it.
414
+ const confirmed = request.toolCallId === undefined
415
+ ? undefined
416
+ : run.unconfirmedToolCalls.get(request.toolCallId);
417
+ if (confirmed) await confirmed;
418
+ return this.core.permissions.requestPermission(run.state, run, request);
419
+ },
420
+ });
421
+ const toolOutcome = Promise.resolve()
422
+ .then(() => this.core.providers.provideRunToolHandle(run, toolProviderContext))
423
+ .then(
424
+ (value): IResolverOutcome<IFlexToolHandle | undefined> => ({ source: 'tools', success: true, value }),
425
+ (error): IResolverOutcome<IFlexToolHandle | undefined> => ({ source: 'tools', success: false, error }),
426
+ );
427
+ let resolveAbort!: (outcome: IResolverOutcome<never>) => void;
428
+ const abortOutcome = new Promise<IResolverOutcome<never>>((resolve) => {
429
+ resolveAbort = resolve;
430
+ });
431
+ const onAbort = () => resolveAbort({
432
+ source: 'abort',
433
+ success: false,
434
+ error: signal.reason ?? new FlexHarnessAbortError(),
435
+ });
436
+ signal.addEventListener('abort', onAbort, { once: true });
437
+ if (signal.aborted) onAbort();
438
+ let model: IFlexResolvedModel | undefined;
439
+ let toolHandle: IFlexToolHandle | undefined;
440
+ try {
441
+ const first = await Promise.race([modelOutcome, toolOutcome, abortOutcome]);
442
+ if (!first.success) {
443
+ const failure = first.source === 'abort'
444
+ ? first.error
445
+ : this.core.projectExternalError(run, first.error, first.source === 'model' ? 'modelResolver' : 'toolProvider');
446
+ if (first.source !== 'abort') this.abortRunInternally(run, failure);
447
+ if (first.source !== 'tools') this.core.providers.observeDetachedToolProvider(toolOutcome, run);
448
+ throw failure;
449
+ }
450
+ try {
451
+ if (first.source === 'model') model = normalizeResolvedModel(first.value as IFlexResolvedModel);
452
+ else toolHandle = this.core.providers.normalizeToolHandle(run, first.value as IFlexToolHandle | undefined);
453
+ } catch (error) {
454
+ const projected = first.source === 'model'
455
+ ? this.core.projectExternalError(run, error, 'modelResolver')
456
+ : this.core.projectExternalError(run, error, 'toolProvider');
457
+ this.abortRunInternally(run, projected);
458
+ if (first.source === 'model') this.core.providers.observeDetachedToolProvider(toolOutcome, run);
459
+ throw projected;
460
+ }
461
+ const second = await Promise.race([
462
+ first.source === 'model' ? toolOutcome : modelOutcome,
463
+ abortOutcome,
464
+ ]);
465
+ if (!second.success) {
466
+ const failure = second.source === 'abort'
467
+ ? second.error
468
+ : this.core.projectExternalError(run, second.error, second.source === 'model' ? 'modelResolver' : 'toolProvider');
469
+ if (second.source !== 'abort') this.abortRunInternally(run, failure);
470
+ if (!toolHandle && first.source === 'model' && second.source === 'abort') {
471
+ this.core.providers.observeDetachedToolProvider(toolOutcome, run);
472
+ }
473
+ throw failure;
474
+ }
475
+ try {
476
+ if (second.source === 'model') model = normalizeResolvedModel(second.value as IFlexResolvedModel);
477
+ else toolHandle = this.core.providers.normalizeToolHandle(run, second.value as IFlexToolHandle | undefined);
478
+ } catch (error) {
479
+ const projected = second.source === 'model'
480
+ ? this.core.projectExternalError(run, error, 'modelResolver')
481
+ : this.core.projectExternalError(run, error, 'toolProvider');
482
+ this.abortRunInternally(run, projected);
483
+ throw projected;
484
+ }
485
+ signal.throwIfAborted();
486
+ run.modelResolution = model!;
487
+ run.toolProviderContext = toolProviderContext as Readonly<IFlexToolProviderContext<unknown>>;
488
+ run.isReadOnlyHostCall = toolHandle?.isReadOnlyToolCall;
489
+ let catalog: FlexToolCatalog<unknown> | undefined;
490
+ if (toolHandle?.catalog) {
491
+ try {
492
+ catalog = await FlexToolCatalog.create(toolHandle.catalog, this.core.toolCatalogOptions, {
493
+ results: this.core.stores.results !== undefined,
494
+ });
495
+ if (catalog.codeMode) run.codeSandbox = await FlexCodeSandbox.load();
496
+ } catch (error) {
497
+ throw this.core.projectExternalError(run, error, 'toolProvider');
498
+ }
499
+ signal.throwIfAborted();
500
+ }
501
+ let tools: TFlexAgentToolSet | undefined;
502
+ try {
503
+ const providedTools = toolHandle?.tools;
504
+ const combinedTools: Record<string, unknown> = { ...(providedTools ?? {}) };
505
+ Object.assign(combinedTools, this.createBuiltInTools(run));
506
+ if (catalog) assertNoCatalogCollisions(catalog, combinedTools);
507
+ const globalNames = run.allowedTools === undefined ? undefined : new Set(run.allowedTools);
508
+ const agentNames = run.agentAllowedTools === undefined ? undefined : new Set(run.agentAllowedTools);
509
+ const admitted = (name: string): boolean =>
510
+ (globalNames === undefined || globalNames.has(name))
511
+ && (agentNames === undefined || agentNames.has(name));
512
+ const admittedTools = globalNames === undefined && agentNames === undefined
513
+ ? combinedTools
514
+ : Object.fromEntries(Object.entries(combinedTools).filter(([name]) => admitted(name)));
515
+ const admittedCatalog = catalog && (globalNames === undefined && agentNames === undefined
516
+ ? catalog
517
+ : catalog.restrict(catalog.methods.filter((name) => name === FLEX_RESULT_METHOD || admitted(name))));
518
+ if (admittedCatalog && admittedCatalog.methods.length > 0) {
519
+ run.toolCatalog = admittedCatalog;
520
+ Object.assign(admittedTools, this.core.toolDispatch.createCatalogTools(run, admittedCatalog));
521
+ }
522
+ tools = Object.keys(admittedTools).length > 0
523
+ ? this.core.toolDispatch.trackToolCalls(run, wrapToolSet(
524
+ admittedTools as TFlexAgentToolSet,
525
+ this.core.toolOutputLimits,
526
+ (toolError) => this.core.projectExternalError(run, toolError, 'toolExecution'),
527
+ (output) => this.core.results.budgetOutput(run, output),
528
+ ))
529
+ : undefined;
530
+ } catch (error) {
531
+ throw this.core.projectExternalError(run, error, 'toolProvider');
532
+ }
533
+ let closed = false;
534
+ let closePromise: Promise<void> | undefined;
535
+ const close = toolHandle?.close
536
+ ? async () => {
537
+ if (closed) return;
538
+ // Inference has ended before its tool lease closes. Fence follow-up admission
539
+ // synchronously, even when external cleanup has not settled yet.
540
+ run.steering.input.close();
541
+ closePromise ??= Promise.resolve()
542
+ .then(() => toolHandle!.close!())
543
+ .then(() => {
544
+ closed = true;
545
+ })
546
+ .catch((cleanupError) => {
547
+ closePromise = undefined;
548
+ throw this.core.projectExternalError(run, cleanupError, 'toolCleanup');
549
+ });
550
+ await closePromise;
551
+ }
552
+ : undefined;
553
+ return {
554
+ model: model!.model,
555
+ ...(options.system ?? model!.system ? { system: options.system ?? model!.system } : {}),
556
+ ...(tools ? {
557
+ tools,
558
+ isReadOnlyToolCall: ({ toolName, input }: { toolName: string; input: unknown }) =>
559
+ this.core.toolDispatch.isReadOnlyToolCall(run, toolName, input),
560
+ } : {}),
561
+ ...(model!.providerOptions ? { providerOptions: model!.providerOptions } : {}),
562
+ ...(model!.cache === undefined ? {} : { cache: model!.cache }),
563
+ ...(options.maxSteps ?? model!.maxSteps ? { maxSteps: options.maxSteps ?? model!.maxSteps } : {}),
564
+ ...(close ? { close } : {}),
565
+ };
566
+ } catch (error) {
567
+ if (toolHandle) {
568
+ try {
569
+ await this.core.providers.closePartialToolHandle(toolHandle, run);
570
+ } catch (cleanupError) {
571
+ throw combineErrors([error, cleanupError]);
572
+ }
573
+ }
574
+ throw error;
575
+ } finally {
576
+ signal.removeEventListener('abort', onAbort);
577
+ }
578
+ }
579
+
580
+ public enabledBuiltInToolNames(run: IActiveRun): Set<string> {
581
+ const names = new Set<string>();
582
+ if (run.subagents.size > 0 && (run.stored.session.depth ?? 0) < this.core.maxSubagentDepth) {
583
+ names.add('delegate');
584
+ names.add('delegate_result');
585
+ names.add('delegate_send');
586
+ }
587
+ if (this.core.builtInTools.renameSession) names.add('rename_session');
588
+ const projectManagement = this.core.builtInTools.projectManagement;
589
+ if (projectManagement?.task) names.add('task');
590
+ if (projectManagement?.goal) names.add('goal');
591
+ if (projectManagement?.scratchpad) names.add('scratchpad');
592
+ return names;
593
+ }
594
+
595
+ public assertNoBuiltInToolCollisions(run: IActiveRun, tools: TFlexAgentToolSet): void {
596
+ const reserved = this.enabledBuiltInToolNames(run);
597
+ const collision = Object.keys(tools).find((name) => reserved.has(name));
598
+ if (collision) {
599
+ throw new FlexHarnessValidationError(
600
+ `Tool provider cannot define enabled built-in tool "${collision}".`,
601
+ );
602
+ }
603
+ }
604
+
605
+ private createBuiltInTools(run: IActiveRun): Record<string, unknown> {
606
+ const tools: Record<string, unknown> = {};
607
+ if (run.subagents.size > 0 && (run.stored.session.depth ?? 0) < this.core.maxSubagentDepth) {
608
+ tools.delegate = this.core.subagentTasks.createSubagentTool(run);
609
+ tools.delegate_result = this.core.subagentTasks.createSubagentResultTool(run);
610
+ tools.delegate_send = this.core.subagentTasks.createSubagentSendTool(run);
611
+ }
612
+ if (this.core.builtInTools.renameSession) tools.rename_session = this.createRenameSessionTool(run);
613
+ const projectManagement = this.core.builtInTools.projectManagement;
614
+ if (projectManagement?.task) tools.task = this.core.projects.createProjectTaskTool(run);
615
+ if (projectManagement?.goal) tools.goal = this.core.projects.createProjectGoalTool(run);
616
+ if (projectManagement?.scratchpad) tools.scratchpad = this.core.projects.createProjectScratchpadTool(run);
617
+ return tools;
618
+ }
619
+
620
+ private createRenameSessionTool(run: IActiveRun): unknown {
621
+ return plugins.tool({
622
+ description: 'Set the current FlexHarness session title.',
623
+ inputSchema: plugins.z.object({
624
+ title: plugins.z.string().min(1).max(FLEX_PROJECT_MANAGEMENT_LIMITS.maxTitleBytes),
625
+ }).strict(),
626
+ execute: async ({ title }: { title: string }) => {
627
+ validateUtf8String(
628
+ title,
629
+ 'title',
630
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxTitleBytes,
631
+ true,
632
+ );
633
+ let session!: IFlexSession;
634
+ await this.core.mutateScope(run.state, () => {
635
+ if (run.state.sessions.get(run.sessionId) !== run.stored) {
636
+ throw new FlexHarnessNotFoundError('Session', run.sessionId);
637
+ }
638
+ run.stored.session.title = title;
639
+ run.stored.session.updatedAt = new Date().toISOString();
640
+ session = publicSnapshot(run.stored.session);
641
+ });
642
+ this.core.emitEvent(run.scopeId, session, {
643
+ type: 'session.updated',
644
+ session,
645
+ });
646
+ return { session };
647
+ },
648
+ });
649
+ }
650
+
651
+ public abortExactRun(
652
+ state: IStorageState,
653
+ sessionId: string,
654
+ runId: string,
655
+ reason: unknown,
656
+ ): boolean {
657
+ const active = state.activeRuns.get(sessionId);
658
+ if (!active || active.runId !== runId || active.phase === 'finalizing' || active.phase === 'promoting') {
659
+ return false;
660
+ }
661
+ const cancellation = reason instanceof FlexHarnessAbortError
662
+ ? reason
663
+ : this.core.trustInternalError(new FlexHarnessAbortError('The parent run was aborted.'));
664
+ this.cancelRun(active, cancellation);
665
+ return true;
666
+ }
667
+
668
+ public createReservation(
669
+ run: IActiveRun,
670
+ prompt: INormalizedFlexPrompt,
671
+ ): { userMessage: IFlexMessage; assistantMessage: IFlexMessage } {
672
+ const timestamp = new Date().toISOString();
673
+ const userMessage: IFlexMessage = {
674
+ messageId: run.userMessageId,
675
+ sessionId: run.sessionId,
676
+ runId: run.runId,
677
+ role: 'user',
678
+ status: 'streaming',
679
+ createdAt: timestamp,
680
+ parts: this.userMessageParts(prompt),
681
+ };
682
+ const assistantMessage: IFlexMessage = {
683
+ messageId: run.assistantMessageId,
684
+ sessionId: run.sessionId,
685
+ runId: run.runId,
686
+ role: 'assistant',
687
+ status: 'streaming',
688
+ createdAt: timestamp,
689
+ parts: [],
690
+ };
691
+ return { userMessage, assistantMessage };
692
+ }
693
+
694
+ private userMessageParts(prompt: INormalizedFlexPrompt): TFlexMessagePart[] {
695
+ return prompt.parts.map((part) => {
696
+ if (part.type === 'text') {
697
+ return { partId: plugins.crypto.randomUUID(), type: 'text' as const, text: part.text };
698
+ }
699
+ return {
700
+ partId: plugins.crypto.randomUUID(),
701
+ type: 'attachment' as const,
702
+ attachmentType: part.type,
703
+ ...describeAttachment(part),
704
+ };
705
+ });
706
+ }
707
+
708
+ /**
709
+ * Ends steering intake once the run's generation has settled and fixes what became of its
710
+ * steers. A run that stays in the model context reports the steers its generation did not
711
+ * record; a run that does not, because it failed, was stopped before any model output or its
712
+ * outcome could not be established, reports every steer it accepted, since the model context
713
+ * of later turns holds none of them. The bytes of the steers not taken in are released.
714
+ */
715
+ public closeRunSteering(steering: IRunSteering): IRunSteeringOutcome {
716
+ if (steering.outcome) return steering.outcome;
717
+ steering.input.close();
718
+ const keptInContext = steering.keptInContext === true;
719
+ const unappliedSteerIds: string[] = [];
720
+ let heldBytes = 0;
721
+ for (const [steerId, entry] of steering.accepted) {
722
+ const applied = steering.applied.has(steerId);
723
+ if (!applied) heldBytes += entry.byteSize;
724
+ if (!keptInContext || !applied) unappliedSteerIds.push(steerId);
725
+ }
726
+ steering.stored.outstandingPromptBytes = Math.max(0, steering.stored.outstandingPromptBytes - heldBytes);
727
+ steering.outcome = Object.freeze({
728
+ keptInContext,
729
+ unappliedSteerIds: Object.freeze(unappliedSteerIds),
730
+ });
731
+ return steering.outcome;
732
+ }
733
+
734
+ /**
735
+ * Projects a steer the run's generation took in as a user message of the run. The assistant
736
+ * message that answered before it is completed and the run answers on in a new one; a steer
737
+ * taken in before the run answered anything is placed before its still empty assistant message.
738
+ */
739
+ public async applyRunSteer(run: IActiveRun, applied: plugins.IAgentAppliedInput): Promise<void> {
740
+ const steering = run.steering;
741
+ const accepted = steering.accepted.get(applied.inputId);
742
+ if (
743
+ !accepted
744
+ || applied.generationId !== run.runId
745
+ || steering.applied.has(applied.inputId)
746
+ || steering.outcome !== undefined
747
+ ) {
748
+ throw new Error(`Steer "${applied.inputId}" was taken in without being accepted by its run.`);
749
+ }
750
+ // The generation recorded the steer durably: it is in the model context whenever the run is.
751
+ steering.applied.add(applied.inputId);
752
+ steering.stored.outstandingPromptBytes = Math.max(
753
+ 0,
754
+ steering.stored.outstandingPromptBytes - accepted.byteSize,
755
+ );
756
+ const timestamp = new Date().toISOString();
757
+ const steerMessage: IFlexMessage = {
758
+ messageId: plugins.crypto.randomUUID(),
759
+ sessionId: run.sessionId,
760
+ runId: run.runId,
761
+ role: 'user',
762
+ status: 'completed',
763
+ createdAt: timestamp,
764
+ completedAt: timestamp,
765
+ steerId: applied.inputId,
766
+ parts: this.userMessageParts(accepted.prompt),
767
+ };
768
+ const answered = run.callbackParts.length > 0;
769
+ const answeredAssistantId = run.assistantMessageId;
770
+ let completedAssistant: IFlexMessage | undefined;
771
+ const nextAssistant: IFlexMessage | undefined = answered
772
+ ? {
773
+ messageId: plugins.crypto.randomUUID(),
774
+ sessionId: run.sessionId,
775
+ runId: run.runId,
776
+ role: 'assistant',
777
+ status: 'streaming',
778
+ createdAt: timestamp,
779
+ parts: [],
780
+ }
781
+ : undefined;
782
+ const closePublication = this.core.openPublication(run.state, run.sessionId);
783
+ try {
784
+ await this.core.mutateProjection(run.state, run.stored, () => {
785
+ const index = run.stored.messages.findIndex((message) => message.messageId === answeredAssistantId);
786
+ if (index < 0) throw new Error('The steered run has no projected assistant message.');
787
+ if (!nextAssistant) {
788
+ run.stored.messages.splice(index, 0, cloneSerializable(steerMessage));
789
+ return;
790
+ }
791
+ completedAssistant = {
792
+ ...cloneSerializable(run.stored.messages[index]),
793
+ status: 'completed',
794
+ completedAt: timestamp,
795
+ parts: cloneSerializable(run.callbackParts),
796
+ ...(run.modelResolution ? { model: cloneSerializable(run.modelResolution.identity) } : {}),
797
+ };
798
+ run.stored.messages.splice(
799
+ index,
800
+ 1,
801
+ cloneSerializable(completedAssistant),
802
+ cloneSerializable(steerMessage),
803
+ cloneSerializable(nextAssistant),
804
+ );
805
+ });
806
+ if (completedAssistant && nextAssistant) {
807
+ run.assistantMessageId = nextAssistant.messageId;
808
+ run.reservedAssistantMessage = publicSnapshot(nextAssistant);
809
+ run.callbackParts = [];
810
+ run.reasoningPartIds.clear();
811
+ this.core.emitEvent(run.scopeId, run.stored.session, {
812
+ type: 'message.updated',
813
+ runId: run.runId,
814
+ messageId: completedAssistant.messageId,
815
+ message: publicSnapshot(completedAssistant),
816
+ });
817
+ }
818
+ this.core.emitEvent(run.scopeId, run.stored.session, {
819
+ type: 'message.created',
820
+ runId: run.runId,
821
+ messageId: steerMessage.messageId,
822
+ message: publicSnapshot(steerMessage),
823
+ });
824
+ if (nextAssistant) {
825
+ this.core.emitEvent(run.scopeId, run.stored.session, {
826
+ type: 'message.created',
827
+ runId: run.runId,
828
+ messageId: nextAssistant.messageId,
829
+ message: publicSnapshot(nextAssistant),
830
+ });
831
+ }
832
+ this.core.emitEvent(run.scopeId, run.stored.session, {
833
+ type: 'prompt.steer.applied',
834
+ queueId: run.queueId,
835
+ runId: run.runId,
836
+ steerId: applied.inputId,
837
+ messageId: steerMessage.messageId,
838
+ });
839
+ } finally {
840
+ closePublication();
841
+ }
842
+ }
843
+
844
+ private buildTerminal(
845
+ run: IActiveRun,
846
+ status: 'completed' | 'failed' | 'cancelled',
847
+ result?: IRunResultProjection,
848
+ error?: Error,
849
+ ): IFlexTerminalProjection {
850
+ const timestamp = new Date().toISOString();
851
+ const userMessage: IFlexMessage = cloneSerializable(
852
+ run.reservedUserMessage
853
+ ?? run.stored.messages.find((message) => message.messageId === run.userMessageId)
854
+ ?? {
855
+ messageId: run.userMessageId,
856
+ sessionId: run.sessionId,
857
+ runId: run.runId,
858
+ role: 'user',
859
+ status: 'streaming',
860
+ createdAt: timestamp,
861
+ parts: [],
862
+ } satisfies IFlexMessage,
863
+ );
864
+ const assistantMessage: IFlexMessage = cloneSerializable(
865
+ run.reservedAssistantMessage
866
+ ?? run.stored.messages.find((message) => message.messageId === run.assistantMessageId)
867
+ ?? {
868
+ messageId: run.assistantMessageId,
869
+ sessionId: run.sessionId,
870
+ runId: run.runId,
871
+ role: 'assistant',
872
+ status: 'streaming',
873
+ createdAt: timestamp,
874
+ parts: [],
875
+ } satisfies IFlexMessage,
876
+ );
877
+ assistantMessage.parts = cloneSerializable(run.callbackParts);
878
+ const terminalParts = assistantMessage.parts;
879
+ for (const part of terminalParts) {
880
+ if (part.type === 'reasoning' && part.status === 'running') {
881
+ part.status = status === 'completed' ? 'completed' : 'cancelled';
882
+ part.completedAt = timestamp;
883
+ } else if (part.type === 'tool' && part.status === 'running') {
884
+ part.status = status === 'cancelled' ? 'cancelled' : 'failed';
885
+ delete part.output;
886
+ part.error = status === 'cancelled'
887
+ ? 'Tool execution was cancelled.'
888
+ : 'Tool execution ended without a terminal event.';
889
+ part.completedAt = timestamp;
890
+ }
891
+ }
892
+ userMessage.status = status;
893
+ userMessage.completedAt = timestamp;
894
+ assistantMessage.status = status;
895
+ assistantMessage.completedAt = timestamp;
896
+ if (run.modelResolution) assistantMessage.model = cloneSerializable(run.modelResolution.identity);
897
+ if (status === 'completed' && result) {
898
+ if (!assistantMessage.parts.some((part) => part.type === 'text')) {
899
+ assistantMessage.parts.push({
900
+ partId: plugins.crypto.randomUUID(),
901
+ type: 'text',
902
+ text: truncateUtf8(result.text, this.core.callbackLimits.maxOutputBytes),
903
+ });
904
+ }
905
+ assistantMessage.usage = cloneSerializable(result.usage);
906
+ } else {
907
+ assistantMessage.usage = cloneSerializable(run.usage);
908
+ const message = truncateUtf8(error?.message ?? externalErrorFallback.message, maxTransferMetadataBytes);
909
+ const limit = error instanceof FlexHarnessExternalError ? error.limit : undefined;
910
+ for (const failedMessage of [userMessage, assistantMessage]) {
911
+ failedMessage.error = message;
912
+ if (limit) failedMessage.limit = cloneSerializable(limit);
913
+ }
914
+ }
915
+ return {
916
+ runId: run.runId,
917
+ status,
918
+ userMessage,
919
+ assistantMessage,
920
+ ...(run.modelResolution ? { model: cloneSerializable(run.modelResolution.identity) } : {}),
921
+ ...(status === 'completed' && result
922
+ ? {
923
+ usage: cloneSerializable(result.usage),
924
+ finishReason: result.finishReason,
925
+ steps: result.steps,
926
+ }
927
+ : { usage: cloneSerializable(run.usage) }),
928
+ };
929
+ }
930
+
931
+ private async promoteCompletedTerminal(
932
+ run: IActiveRun,
933
+ terminal: IFlexTerminalProjection,
934
+ ): Promise<void> {
935
+ await this.core.mutateProjection(run.state, run.stored, () => {
936
+ this.replaceVisibleRun(run.stored, terminal);
937
+ run.stored.stagedTerminals = run.stored.stagedTerminals.filter((entry) => entry.runId !== run.runId);
938
+ }, true);
939
+ if (!this.core.isTombstoned(run)) {
940
+ await this.core.mutateScope(run.state, () => {
941
+ if (run.state.sessions.get(run.sessionId) !== run.stored) return;
942
+ const timestamp = terminal.assistantMessage.completedAt!;
943
+ run.stored.session.status = 'idle';
944
+ run.stored.session.activity = {
945
+ runId: run.runId,
946
+ status: 'idle',
947
+ startedAt: run.stored.session.activity.startedAt,
948
+ completedAt: timestamp,
949
+ };
950
+ run.stored.session.updatedAt = timestamp;
951
+ }, true);
952
+ }
953
+ }
954
+
955
+ private async publishFailedTerminal(
956
+ run: IActiveRun,
957
+ terminal: IFlexTerminalProjection,
958
+ ): Promise<void> {
959
+ await this.core.mutateProjection(run.state, run.stored, () => {
960
+ this.replaceVisibleRun(run.stored, terminal);
961
+ run.stored.stagedTerminals = run.stored.stagedTerminals.filter((entry) => entry.runId !== run.runId);
962
+ }, true);
963
+ await this.core.mutateScope(run.state, () => {
964
+ if (run.state.sessions.get(run.sessionId) !== run.stored) return;
965
+ const timestamp = terminal.assistantMessage.completedAt!;
966
+ const activityStatus = terminal.status === 'completed' ? 'idle' : terminal.status;
967
+ run.stored.session.status = activityStatus;
968
+ run.stored.session.activity = {
969
+ runId: run.runId,
970
+ status: activityStatus,
971
+ startedAt: run.stored.session.activity.startedAt,
972
+ completedAt: timestamp,
973
+ ...terminalActivityError(terminal.assistantMessage),
974
+ };
975
+ run.stored.session.updatedAt = timestamp;
976
+ }, true);
977
+ }
978
+
979
+ private replaceStagedTerminal(stored: IStoredSessionState, terminal: IFlexTerminalProjection): void {
980
+ stored.stagedTerminals = stored.stagedTerminals.filter((entry) => entry.runId !== terminal.runId);
981
+ stored.stagedTerminals.push(cloneSerializable(terminal));
982
+ }
983
+
984
+ /**
985
+ * Puts a run's terminal messages in place of their projected versions: its prompt, and its last
986
+ * assistant message. The steers a run took in, and the assistant messages it completed before
987
+ * them, stay where they are. A terminal message that is not projected yet is added: the prompt
988
+ * before the run's other messages, the assistant message after them.
989
+ */
990
+ public replaceVisibleRun(stored: IStoredSessionState, terminal: IFlexTerminalProjection): void {
991
+ const replacements = new Map([
992
+ [terminal.userMessage.messageId, terminal.userMessage],
993
+ [terminal.assistantMessage.messageId, terminal.assistantMessage],
994
+ ]);
995
+ const placed = new Set<string>();
996
+ stored.messages = stored.messages.map((message) => {
997
+ const replacement = message.runId === terminal.runId
998
+ ? replacements.get(message.messageId)
999
+ : undefined;
1000
+ if (!replacement) return message;
1001
+ placed.add(replacement.messageId);
1002
+ return cloneSerializable(replacement);
1003
+ });
1004
+ const firstIndex = stored.messages.findIndex((message) => message.runId === terminal.runId);
1005
+ if (!placed.has(terminal.userMessage.messageId)) {
1006
+ stored.messages.splice(
1007
+ firstIndex < 0 ? stored.messages.length : firstIndex,
1008
+ 0,
1009
+ cloneSerializable(terminal.userMessage),
1010
+ );
1011
+ }
1012
+ if (!placed.has(terminal.assistantMessage.messageId)) {
1013
+ const lastIndex = stored.messages.reduce(
1014
+ (last, message, index) => message.runId === terminal.runId ? index : last,
1015
+ -1,
1016
+ );
1017
+ stored.messages.splice(
1018
+ lastIndex < 0 ? stored.messages.length : lastIndex + 1,
1019
+ 0,
1020
+ cloneSerializable(terminal.assistantMessage),
1021
+ );
1022
+ }
1023
+ }
1024
+
1025
+ private emitTerminalProjection(run: IActiveRun, terminal: IFlexTerminalProjection): void {
1026
+ for (const part of terminal.assistantMessage.parts) {
1027
+ const callbackPart = run.callbackParts.find((entry) => entry.partId === part.partId);
1028
+ const shouldComplete = part.type === 'text'
1029
+ ? callbackPart?.type === 'text'
1030
+ : part.type === 'reasoning'
1031
+ ? callbackPart?.type === 'reasoning' && callbackPart.status === 'running'
1032
+ : part.type === 'tool'
1033
+ ? callbackPart?.type === 'tool' && callbackPart.status === 'running'
1034
+ : false;
1035
+ if (shouldComplete) {
1036
+ this.core.emitPartEvent(run, 'part.completed', part);
1037
+ }
1038
+ }
1039
+ for (const message of [terminal.userMessage, terminal.assistantMessage]) {
1040
+ this.core.emitEvent(run.scopeId, run.stored.session, {
1041
+ type: 'message.updated',
1042
+ runId: run.runId,
1043
+ messageId: message.messageId,
1044
+ message: publicSnapshot(message),
1045
+ });
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * Ends a run that fails without publishing a terminal projection, because its namespace was
1051
+ * fenced or retired or its durable outcome could not be established: listeners still receive
1052
+ * its terminal message events and its one `run.finished`. Nothing here is persisted; the next
1053
+ * load of the namespace repairs the stored projection from the canonical outcome.
1054
+ */
1055
+ private emitUnpublishedRunFinish(run: IActiveRun, error: unknown, generated: boolean): void {
1056
+ if (run.finishedEventEmitted || run.state.tombstones.has(run.sessionId)) return;
1057
+ const cancelled = run.ownerCancellation !== undefined && run.internalFailure === undefined;
1058
+ const terminalError = cancelled
1059
+ ? run.ownerCancellation!
1060
+ : error instanceof FlexHarnessRunError
1061
+ ? error
1062
+ : this.core.projectExternalError(run, error, generated ? 'persistence' : 'agentSession');
1063
+ const terminal = this.buildTerminal(run, cancelled ? 'cancelled' : 'failed', undefined, terminalError);
1064
+ this.emitTerminalProjection(run, terminal);
1065
+ this.emitFinalRunEvent(run, terminal.assistantMessage, terminalError, cancelled);
1066
+ }
1067
+
1068
+ private emitFinalRunEvent(
1069
+ run: IActiveRun,
1070
+ message: IFlexMessage,
1071
+ error: unknown,
1072
+ cancelled: boolean,
1073
+ ): void {
1074
+ const status: IFlexRunFinishedEvent['status'] = error
1075
+ ? cancelled ? 'cancelled' : 'failed'
1076
+ : 'completed';
1077
+ run.finishedEventEmitted = true;
1078
+ const steering = this.closeRunSteering(run.steering);
1079
+ this.core.emitEvent(run.scopeId, run.stored.session, {
1080
+ type: 'run.finished',
1081
+ runId: run.runId,
1082
+ messageId: run.assistantMessageId,
1083
+ status,
1084
+ message: publicSnapshot(message),
1085
+ ...(error === undefined ? {} : { error: errorToInfo(error) }),
1086
+ keptInContext: steering.keptInContext,
1087
+ unappliedSteerIds: [...steering.unappliedSteerIds],
1088
+ });
1089
+ }
1090
+
1091
+ public cancelRun(
1092
+ run: IActiveRun,
1093
+ reason: FlexHarnessAbortError,
1094
+ context?: IFlexAgentContextInvocation<TScope>,
1095
+ ): void {
1096
+ const invocationContext = context ?? this.core.compaction.createRunCompactorContext(run);
1097
+ // A stopped run takes no further steers; the ones it has not taken in are reported unapplied.
1098
+ run.steering.input.close();
1099
+ if (run.internalFailure === undefined && run.ownerCancellation === undefined) {
1100
+ run.ownerCancellation = reason;
1101
+ run.deferredCompactorContext = invocationContext;
1102
+ this.core.compaction.deferredCompactorContexts.set(run.originCompactorContext, invocationContext);
1103
+ }
1104
+ this.core.compaction.withCompactorContext(
1105
+ (run.deferredCompactorContext ?? invocationContext) as IFlexAgentContextInvocation<TScope>,
1106
+ () => {
1107
+ this.core.permissions.rejectRunPermissions(run.state, run, reason);
1108
+ // The owner stops the generation: what its model produced stays in the model context.
1109
+ if (run.ownerCancellation === reason && !run.stop.signal.aborted) run.stop.abort(reason);
1110
+ if (!run.controller.signal.aborted) run.controller.abort(reason);
1111
+ if (run.phase === 'admitting') return;
1112
+ if (run.scheduleKey) {
1113
+ run.stored.agentSession.cancelScheduledGeneration(run.scheduleKey, reason);
1114
+ } else {
1115
+ run.stored.agentSession.abortCurrentGeneration(reason);
1116
+ }
1117
+ },
1118
+ );
1119
+ }
1120
+
1121
+ public abortRunInternally(
1122
+ run: IActiveRun,
1123
+ error: unknown,
1124
+ context?: IFlexAgentContextInvocation<TScope>,
1125
+ ): void {
1126
+ const invocationContext = context ?? this.core.compaction.createRunCompactorContext(run);
1127
+ run.steering.input.close();
1128
+ if (run.internalFailure === undefined) {
1129
+ run.internalFailure = error ?? new Error('Internal FlexHarness run failure.');
1130
+ run.deferredCompactorContext = invocationContext;
1131
+ this.core.compaction.deferredCompactorContexts.set(run.originCompactorContext, invocationContext);
1132
+ }
1133
+ this.core.compaction.withCompactorContext(
1134
+ (run.deferredCompactorContext ?? invocationContext) as IFlexAgentContextInvocation<TScope>,
1135
+ () => {
1136
+ if (!run.controller.signal.aborted) run.controller.abort(run.internalFailure);
1137
+ run.stored.agentSession.abortCurrentGeneration(run.internalFailure);
1138
+ },
1139
+ );
1140
+ }
1141
+
1142
+ public async rollbackAdmission(
1143
+ run: IActiveRun,
1144
+ projectionReserved: boolean,
1145
+ originalError: Error,
1146
+ ): Promise<unknown[]> {
1147
+ const errors: unknown[] = [];
1148
+ if (run.transaction) {
1149
+ try {
1150
+ await this.core.compaction.withRunCompactorContext(
1151
+ run,
1152
+ () => run.stored.agentSession.finalizeGeneration(run.transaction!, 'interrupted'),
1153
+ );
1154
+ } catch (error) {
1155
+ errors.push(this.core.projectExternalError(run, error, 'agentSession'));
1156
+ }
1157
+ try {
1158
+ const outcome = (await this.core.durableCanonicalOutcomes(run.stored)).get(run.runId);
1159
+ if (outcome !== 'interrupted') {
1160
+ errors.push(this.core.projectExternalError(
1161
+ run,
1162
+ new Error('Prompt admission interruption is not durably established.'),
1163
+ 'persistence',
1164
+ ));
1165
+ }
1166
+ } catch (error) {
1167
+ errors.push(this.core.projectExternalError(run, error, 'persistence'));
1168
+ }
1169
+ }
1170
+ if (projectionReserved || run.stored.metrics.pendingGenerationId === run.runId) {
1171
+ try {
1172
+ await this.core.mutateProjection(run.state, run.stored, () => {
1173
+ if (run.stored.metrics.pendingGenerationId === run.runId) {
1174
+ delete run.stored.metrics.pendingGenerationId;
1175
+ }
1176
+ run.stored.messages = run.stored.messages.filter((message) => message.runId !== run.runId);
1177
+ run.stored.stagedTerminals = run.stored.stagedTerminals.filter((entry) => entry.runId !== run.runId);
1178
+ run.stored.reversionSegments = run.stored.reversionSegments.filter(
1179
+ (segment) => segment.runId !== run.runId,
1180
+ );
1181
+ if (
1182
+ run.stored.pendingReversion?.kind === 'capture'
1183
+ && run.stored.pendingReversion.runId === run.runId
1184
+ ) delete run.stored.pendingReversion;
1185
+ }, true);
1186
+ } catch (error) {
1187
+ errors.push(this.core.projectExternalError(run, error, 'persistence'));
1188
+ }
1189
+ }
1190
+ if (run.state.sessions.get(run.sessionId) === run.stored && run.stored.session.activity.runId === run.runId) {
1191
+ try {
1192
+ await this.core.mutateScope(run.state, () => {
1193
+ run.stored.session.status = 'idle';
1194
+ run.stored.session.activity = { status: 'idle' };
1195
+ run.stored.session.updatedAt = new Date().toISOString();
1196
+ }, true);
1197
+ } catch (error) {
1198
+ errors.push(this.core.projectExternalError(run, error, 'persistence'));
1199
+ }
1200
+ }
1201
+ if (errors.length > 0) await this.core.lifecycle.fenceNamespace(run.state, run, combineErrors([originalError, ...errors]));
1202
+ return errors;
1203
+ }
1204
+ }