@modelprofile.com/flexharness 9.9.1 → 9.10.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.
@@ -13,6 +13,7 @@ import {
13
13
  FlexHarnessSessionBusyError,
14
14
  FlexHarnessSlashCommandReentryError,
15
15
  FlexHarnessSlashCommandUnavailableError,
16
+ FlexHarnessSteerRejectedError,
16
17
  FlexHarnessStoreConflictError,
17
18
  FlexHarnessStoreCommitUncertainError,
18
19
  FlexHarnessValidationError,
@@ -58,6 +59,8 @@ import type {
58
59
  IFlexPermissionRequestInput,
59
60
  IFlexPermissionSnapshot,
60
61
  IFlexProjectGoalResult,
62
+ IFlexSteerAdmission,
63
+ IFlexSteerInput,
61
64
  IFlexProjectManagementSessionContext,
62
65
  IFlexProjectManagementSnapshot,
63
66
  IFlexProjectManagementTombstone,
@@ -407,6 +410,31 @@ interface IActiveRun {
407
410
  delegatedRunAdmissionOwner?: IDelegatedRunAdmissionOwner;
408
411
  delegatedRunAdmissionClose?: Promise<void>;
409
412
  completion: Promise<IFlexPromptResult>;
413
+ steering: IRunSteering;
414
+ /** Stops the run's generation for its owner, so what the model produced stays in context. */
415
+ stop: AbortController;
416
+ }
417
+
418
+ /** The steers of a run: the input its generation takes in, and the prompts it accepted for it. */
419
+ interface IRunSteering {
420
+ readonly input: plugins.AgentPendingInputQueue;
421
+ /** The session whose outstanding prompt bytes hold the bytes of the steers not taken in yet. */
422
+ readonly stored: IStoredSessionState;
423
+ /** Every steer the run accepted, in arrival order, by steer id. */
424
+ readonly accepted: Map<string, { prompt: INormalizedFlexPrompt; byteSize: number }>;
425
+ /** Accepted steers the generation recorded durably as user messages of the run. */
426
+ readonly applied: Set<string>;
427
+ /** Whether the run stays in the model context of later turns, once that is established. */
428
+ keptInContext?: boolean;
429
+ /** What became of the run's steers, fixed when the run ends. */
430
+ outcome?: IRunSteeringOutcome;
431
+ }
432
+
433
+ interface IRunSteeringOutcome {
434
+ /** Whether the run's prompt, the steers it took in and what its model produced stay in context. */
435
+ readonly keptInContext: boolean;
436
+ /** Accepted steers the model context of later turns does not hold, in arrival order. */
437
+ readonly unappliedSteerIds: readonly string[];
410
438
  }
411
439
 
412
440
  interface IDelegatedRunAdmissionSeed {
@@ -472,6 +500,8 @@ interface IQueuedPrompt {
472
500
  options?: IFlexPromptOptions;
473
501
  delegationSeed?: IDelegatedRunAdmissionSeed;
474
502
  byteSize: number;
503
+ /** The steering of the run the prompt started, once it started one. */
504
+ steering?: IRunSteering;
475
505
  completion: Promise<IFlexPromptResult>;
476
506
  resolveCompletion: (result: IFlexPromptResult) => void;
477
507
  rejectCompletion: (error: unknown) => void;
@@ -2860,6 +2890,89 @@ export class FlexHarness<TScope = unknown> {
2860
2890
  );
2861
2891
  }
2862
2892
 
2893
+ /**
2894
+ * Steers the running run of the prompt `queueId`: the run takes `steer.prompt` in as a user
2895
+ * message at its next step boundary, after the tool results of its current model step are
2896
+ * recorded and before its next model call. A running tool call or permission wait is not
2897
+ * interrupted. A steer the run has not taken in when it ends is reported in its
2898
+ * `unappliedSteerIds`; a steer for a prompt that has no running run, or whose run is ending,
2899
+ * is refused with `FlexHarnessSteerRejectedError` reason `not-running` and belongs to the
2900
+ * next turn.
2901
+ */
2902
+ public async steerPrompt(
2903
+ scopeId: string,
2904
+ sessionId: string,
2905
+ queueId: string,
2906
+ steer: IFlexSteerInput,
2907
+ ): Promise<IFlexSteerAdmission> {
2908
+ validateIdentifier(queueId, 'queueId');
2909
+ requireTransferIdentifier(queueId, 'queueId');
2910
+ validateIdentifier(steer.steerId, 'steerId');
2911
+ requireTransferIdentifier(steer.steerId, 'steerId');
2912
+ this.assertOpen();
2913
+ const prompt = normalizeFlexPrompt(steer.prompt);
2914
+ const byteSize = this.promptAdmissionByteSize(steer.prompt, {});
2915
+ const { state } = await this.resolveState(scopeId);
2916
+ this.assertStateAcceptingWork(state);
2917
+ const stored = this.requireSession(state, sessionId);
2918
+ this.assertSessionWritable(stored);
2919
+ if (stored.session.agent !== undefined) {
2920
+ throw new FlexHarnessValidationError(
2921
+ 'Subagent sessions can only be prompted through the foreground delegate tool.',
2922
+ );
2923
+ }
2924
+ const run = state.activeRuns.get(sessionId);
2925
+ if (!run || run.queueId !== queueId) {
2926
+ if (stored.outstandingPromptsById.has(queueId) || stored.terminalPromptQueueEntries.has(queueId)) {
2927
+ throw new FlexHarnessSteerRejectedError('not-running', steer.steerId);
2928
+ }
2929
+ throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
2930
+ }
2931
+ const steering = run.steering;
2932
+ if (!steering.input.closed && !steering.accepted.has(steer.steerId)) {
2933
+ if (
2934
+ steering.accepted.size - steering.applied.size
2935
+ >= this.promptQueueLimits.maxOutstandingPromptsPerSession
2936
+ ) {
2937
+ throw new FlexHarnessQueueFullError(`Session "${sessionId}" has reached its pending steer limit.`);
2938
+ }
2939
+ // Pending steers hold outstanding prompt bytes of the session until they are taken in.
2940
+ if (
2941
+ steering.stored.outstandingPromptBytes + byteSize
2942
+ > this.promptQueueLimits.maxOutstandingBytesPerSession
2943
+ ) {
2944
+ throw new FlexHarnessQueueFullError(
2945
+ `Session "${sessionId}" has reached its outstanding prompt byte limit.`,
2946
+ );
2947
+ }
2948
+ }
2949
+ try {
2950
+ steering.input.add({
2951
+ inputId: steer.steerId,
2952
+ content: cloneSerializable(prompt.modelMessage.content) as Parameters<
2953
+ plugins.IAgentSession['beginGeneration']
2954
+ >[0],
2955
+ });
2956
+ } catch (error) {
2957
+ if (error instanceof plugins.AgentPendingInputRejectedError) {
2958
+ throw new FlexHarnessSteerRejectedError(
2959
+ error.reason === 'duplicate' ? 'duplicate' : 'not-running',
2960
+ steer.steerId,
2961
+ );
2962
+ }
2963
+ throw error;
2964
+ }
2965
+ steering.accepted.set(steer.steerId, { prompt, byteSize });
2966
+ steering.stored.outstandingPromptBytes += byteSize;
2967
+ this.emitEvent(run.scopeId, run.stored.session, {
2968
+ type: 'prompt.steer.accepted',
2969
+ queueId,
2970
+ runId: run.runId,
2971
+ steerId: steer.steerId,
2972
+ });
2973
+ return { steerId: steer.steerId, queueId, runId: run.runId };
2974
+ }
2975
+
2863
2976
  public async abort(scopeId: string, sessionId: string): Promise<boolean> {
2864
2977
  const { scope, state } = await this.resolveState(scopeId);
2865
2978
  const run = state.activeRuns.get(sessionId);
@@ -3966,7 +4079,16 @@ export class FlexHarness<TScope = unknown> {
3966
4079
  }
3967
4080
 
3968
4081
  private createActiveRun(queued: IQueuedPrompt): IActiveRun {
4082
+ const steering: IRunSteering = {
4083
+ input: new plugins.AgentPendingInputQueue(),
4084
+ stored: queued.stored,
4085
+ accepted: new Map(),
4086
+ applied: new Set(),
4087
+ };
4088
+ queued.steering = steering;
3969
4089
  return {
4090
+ steering,
4091
+ stop: new AbortController(),
3970
4092
  subagents: this.subagents,
3971
4093
  state: queued.state,
3972
4094
  stored: queued.stored,
@@ -4410,8 +4532,11 @@ export class FlexHarness<TScope = unknown> {
4410
4532
  const generateOptions = {
4411
4533
  transaction: run.transaction,
4412
4534
  abort: run.controller.signal,
4535
+ stop: run.stop.signal,
4413
4536
  prepare: (context: { generationId: string; abortSignal: AbortSignal }) =>
4414
4537
  this.prepareGeneration(run, options, context.abortSignal),
4538
+ pendingInput: run.steering.input,
4539
+ onInputApplied: (input: plugins.IAgentAppliedInput) => this.applyRunSteer(run, input),
4415
4540
  };
4416
4541
  const generation = this.withRunCompactorContext(run, () => run.scheduleKey
4417
4542
  ? run.stored.agentSession.scheduleGenerate({
@@ -4482,11 +4607,16 @@ export class FlexHarness<TScope = unknown> {
4482
4607
  delete queued.prompt;
4483
4608
  delete queued.options;
4484
4609
  delete queued.delegationSeed;
4610
+ const steering = queued.steering === undefined ? undefined : this.closeRunSteering(queued.steering);
4485
4611
  const terminal: IFlexPromptQueueEntry = {
4486
4612
  ...this.projectPromptQueueEntry(queued),
4487
4613
  status,
4488
4614
  finishedAt: new Date().toISOString(),
4489
4615
  ...(error === undefined ? {} : { error: errorToInfo(error) }),
4616
+ keptInContext: steering?.keptInContext ?? false,
4617
+ ...(steering === undefined || steering.unappliedSteerIds.length === 0
4618
+ ? {}
4619
+ : { unappliedSteerIds: [...steering.unappliedSteerIds] }),
4490
4620
  };
4491
4621
  stored.terminalPromptQueueEntries.set(queued.queueId, terminal);
4492
4622
  while (
@@ -4590,6 +4720,7 @@ export class FlexHarness<TScope = unknown> {
4590
4720
  await this.fenceNamespace(run.state, run, projected);
4591
4721
  throw projected;
4592
4722
  }
4723
+ run.steering.keptInContext = true;
4593
4724
  run.phase = 'promoting';
4594
4725
  try {
4595
4726
  await this.promoteCompletedTerminal(run, terminal);
@@ -4616,6 +4747,7 @@ export class FlexHarness<TScope = unknown> {
4616
4747
  usage: publicSnapshot(terminal.usage!),
4617
4748
  finishReason: terminal.finishReason!,
4618
4749
  steps: terminal.steps!,
4750
+ unappliedSteerIds: [...this.closeRunSteering(run.steering).unappliedSteerIds],
4619
4751
  };
4620
4752
  } catch (error) {
4621
4753
  if (run.state.lifecycle === 'fenced') {
@@ -4680,14 +4812,21 @@ export class FlexHarness<TScope = unknown> {
4680
4812
  try {
4681
4813
  await this.withRunCompactorContext(
4682
4814
  run,
4683
- () => run.stored.agentSession.finalizeGeneration(run.transaction!, desiredOutcome),
4815
+ () => run.stored.agentSession.finalizeGeneration(
4816
+ run.transaction!,
4817
+ desiredOutcome,
4818
+ { stopped: desiredOutcome === 'interrupted' && cancelled },
4819
+ ),
4684
4820
  );
4685
4821
  } catch (finalizationError) {
4686
4822
  errors.push(this.projectExternalError(run, finalizationError, 'agentSession'));
4687
4823
  }
4688
4824
  let outcome: TCanonicalOutcome | undefined;
4689
4825
  try {
4690
- outcome = (await this.durableCanonicalOutcomes(run.stored)).get(run.runId);
4826
+ const snapshot = await run.stored.eventStore.load(run.stored.session.sessionId);
4827
+ const events = snapshot?.events ?? [];
4828
+ outcome = this.canonicalOutcomes(events).get(run.runId);
4829
+ run.steering.keptInContext = plugins.isGenerationInModelContext(events, run.runId);
4691
4830
  } catch (outcomeError) {
4692
4831
  errors.push(this.projectExternalError(run, outcomeError, 'persistence'));
4693
4832
  }
@@ -6238,17 +6377,7 @@ export class FlexHarness<TScope = unknown> {
6238
6377
  role: 'user',
6239
6378
  status: 'streaming',
6240
6379
  createdAt: timestamp,
6241
- parts: prompt.parts.map((part) => {
6242
- if (part.type === 'text') {
6243
- return { partId: plugins.crypto.randomUUID(), type: 'text' as const, text: part.text };
6244
- }
6245
- return {
6246
- partId: plugins.crypto.randomUUID(),
6247
- type: 'attachment' as const,
6248
- attachmentType: part.type,
6249
- ...describeAttachment(part),
6250
- };
6251
- }),
6380
+ parts: this.userMessageParts(prompt),
6252
6381
  };
6253
6382
  const assistantMessage: IFlexMessage = {
6254
6383
  messageId: run.assistantMessageId,
@@ -6262,6 +6391,151 @@ export class FlexHarness<TScope = unknown> {
6262
6391
  return { userMessage, assistantMessage };
6263
6392
  }
6264
6393
 
6394
+ private userMessageParts(prompt: INormalizedFlexPrompt): TFlexMessagePart[] {
6395
+ return prompt.parts.map((part) => {
6396
+ if (part.type === 'text') {
6397
+ return { partId: plugins.crypto.randomUUID(), type: 'text' as const, text: part.text };
6398
+ }
6399
+ return {
6400
+ partId: plugins.crypto.randomUUID(),
6401
+ type: 'attachment' as const,
6402
+ attachmentType: part.type,
6403
+ ...describeAttachment(part),
6404
+ };
6405
+ });
6406
+ }
6407
+
6408
+ /**
6409
+ * Ends steering intake once the run's generation has settled and fixes what became of its
6410
+ * steers. A run that stays in the model context reports the steers its generation did not
6411
+ * record; a run that does not, because it failed, was stopped before any model output or its
6412
+ * outcome could not be established, reports every steer it accepted, since the model context
6413
+ * of later turns holds none of them. The bytes of the steers not taken in are released.
6414
+ */
6415
+ private closeRunSteering(steering: IRunSteering): IRunSteeringOutcome {
6416
+ if (steering.outcome) return steering.outcome;
6417
+ steering.input.close();
6418
+ const keptInContext = steering.keptInContext === true;
6419
+ const unappliedSteerIds: string[] = [];
6420
+ let heldBytes = 0;
6421
+ for (const [steerId, entry] of steering.accepted) {
6422
+ const applied = steering.applied.has(steerId);
6423
+ if (!applied) heldBytes += entry.byteSize;
6424
+ if (!keptInContext || !applied) unappliedSteerIds.push(steerId);
6425
+ }
6426
+ steering.stored.outstandingPromptBytes = Math.max(0, steering.stored.outstandingPromptBytes - heldBytes);
6427
+ steering.outcome = Object.freeze({
6428
+ keptInContext,
6429
+ unappliedSteerIds: Object.freeze(unappliedSteerIds),
6430
+ });
6431
+ return steering.outcome;
6432
+ }
6433
+
6434
+ /**
6435
+ * Projects a steer the run's generation took in as a user message of the run. The assistant
6436
+ * message that answered before it is completed and the run answers on in a new one; a steer
6437
+ * taken in before the run answered anything is placed before its still empty assistant message.
6438
+ */
6439
+ private async applyRunSteer(run: IActiveRun, applied: plugins.IAgentAppliedInput): Promise<void> {
6440
+ const steering = run.steering;
6441
+ const accepted = steering.accepted.get(applied.inputId);
6442
+ if (
6443
+ !accepted
6444
+ || applied.generationId !== run.runId
6445
+ || steering.applied.has(applied.inputId)
6446
+ || steering.outcome !== undefined
6447
+ ) {
6448
+ throw new Error(`Steer "${applied.inputId}" was taken in without being accepted by its run.`);
6449
+ }
6450
+ // The generation recorded the steer durably: it is in the model context whenever the run is.
6451
+ steering.applied.add(applied.inputId);
6452
+ steering.stored.outstandingPromptBytes = Math.max(
6453
+ 0,
6454
+ steering.stored.outstandingPromptBytes - accepted.byteSize,
6455
+ );
6456
+ const timestamp = new Date().toISOString();
6457
+ const steerMessage: IFlexMessage = {
6458
+ messageId: plugins.crypto.randomUUID(),
6459
+ sessionId: run.sessionId,
6460
+ runId: run.runId,
6461
+ role: 'user',
6462
+ status: 'completed',
6463
+ createdAt: timestamp,
6464
+ completedAt: timestamp,
6465
+ steerId: applied.inputId,
6466
+ parts: this.userMessageParts(accepted.prompt),
6467
+ };
6468
+ const answered = run.callbackParts.length > 0;
6469
+ const answeredAssistantId = run.assistantMessageId;
6470
+ let completedAssistant: IFlexMessage | undefined;
6471
+ const nextAssistant: IFlexMessage | undefined = answered
6472
+ ? {
6473
+ messageId: plugins.crypto.randomUUID(),
6474
+ sessionId: run.sessionId,
6475
+ runId: run.runId,
6476
+ role: 'assistant',
6477
+ status: 'streaming',
6478
+ createdAt: timestamp,
6479
+ parts: [],
6480
+ }
6481
+ : undefined;
6482
+ await this.mutateProjection(run.state, run.stored, () => {
6483
+ const index = run.stored.messages.findIndex((message) => message.messageId === answeredAssistantId);
6484
+ if (index < 0) throw new Error('The steered run has no projected assistant message.');
6485
+ if (!nextAssistant) {
6486
+ run.stored.messages.splice(index, 0, cloneSerializable(steerMessage));
6487
+ return;
6488
+ }
6489
+ completedAssistant = {
6490
+ ...cloneSerializable(run.stored.messages[index]),
6491
+ status: 'completed',
6492
+ completedAt: timestamp,
6493
+ parts: cloneSerializable(run.callbackParts),
6494
+ ...(run.modelResolution ? { model: cloneSerializable(run.modelResolution.identity) } : {}),
6495
+ };
6496
+ run.stored.messages.splice(
6497
+ index,
6498
+ 1,
6499
+ cloneSerializable(completedAssistant),
6500
+ cloneSerializable(steerMessage),
6501
+ cloneSerializable(nextAssistant),
6502
+ );
6503
+ });
6504
+ if (completedAssistant && nextAssistant) {
6505
+ run.assistantMessageId = nextAssistant.messageId;
6506
+ run.reservedAssistantMessage = publicSnapshot(nextAssistant);
6507
+ run.callbackParts = [];
6508
+ run.reasoningPartIds.clear();
6509
+ this.emitEvent(run.scopeId, run.stored.session, {
6510
+ type: 'message.updated',
6511
+ runId: run.runId,
6512
+ messageId: completedAssistant.messageId,
6513
+ message: publicSnapshot(completedAssistant),
6514
+ });
6515
+ }
6516
+ this.emitEvent(run.scopeId, run.stored.session, {
6517
+ type: 'message.created',
6518
+ runId: run.runId,
6519
+ messageId: steerMessage.messageId,
6520
+ message: publicSnapshot(steerMessage),
6521
+ });
6522
+ if (nextAssistant) {
6523
+ this.emitEvent(run.scopeId, run.stored.session, {
6524
+ type: 'message.created',
6525
+ runId: run.runId,
6526
+ messageId: nextAssistant.messageId,
6527
+ message: publicSnapshot(nextAssistant),
6528
+ });
6529
+ }
6530
+ this.emitEvent(run.scopeId, run.stored.session, {
6531
+ type: 'prompt.steer.applied',
6532
+ queueId: run.queueId,
6533
+ runId: run.runId,
6534
+ steerId: applied.inputId,
6535
+ messageId: steerMessage.messageId,
6536
+ });
6537
+ }
6538
+
6265
6539
  private buildTerminal(
6266
6540
  run: IActiveRun,
6267
6541
  status: 'completed' | 'failed' | 'cancelled',
@@ -7428,16 +7702,45 @@ export class FlexHarness<TScope = unknown> {
7428
7702
  stored.stagedTerminals.push(cloneSerializable(terminal));
7429
7703
  }
7430
7704
 
7705
+ /**
7706
+ * Puts a run's terminal messages in place of their projected versions: its prompt, and its last
7707
+ * assistant message. The steers a run took in, and the assistant messages it completed before
7708
+ * them, stay where they are. A terminal message that is not projected yet is added: the prompt
7709
+ * before the run's other messages, the assistant message after them.
7710
+ */
7431
7711
  private replaceVisibleRun(stored: IStoredSessionState, terminal: IFlexTerminalProjection): void {
7712
+ const replacements = new Map([
7713
+ [terminal.userMessage.messageId, terminal.userMessage],
7714
+ [terminal.assistantMessage.messageId, terminal.assistantMessage],
7715
+ ]);
7716
+ const placed = new Set<string>();
7717
+ stored.messages = stored.messages.map((message) => {
7718
+ const replacement = message.runId === terminal.runId
7719
+ ? replacements.get(message.messageId)
7720
+ : undefined;
7721
+ if (!replacement) return message;
7722
+ placed.add(replacement.messageId);
7723
+ return cloneSerializable(replacement);
7724
+ });
7432
7725
  const firstIndex = stored.messages.findIndex((message) => message.runId === terminal.runId);
7433
- stored.messages = stored.messages.filter((message) => message.runId !== terminal.runId);
7434
- const insertion = firstIndex < 0 ? stored.messages.length : firstIndex;
7435
- stored.messages.splice(
7436
- insertion,
7437
- 0,
7438
- cloneSerializable(terminal.userMessage),
7439
- cloneSerializable(terminal.assistantMessage),
7440
- );
7726
+ if (!placed.has(terminal.userMessage.messageId)) {
7727
+ stored.messages.splice(
7728
+ firstIndex < 0 ? stored.messages.length : firstIndex,
7729
+ 0,
7730
+ cloneSerializable(terminal.userMessage),
7731
+ );
7732
+ }
7733
+ if (!placed.has(terminal.assistantMessage.messageId)) {
7734
+ const lastIndex = stored.messages.reduce(
7735
+ (last, message, index) => message.runId === terminal.runId ? index : last,
7736
+ -1,
7737
+ );
7738
+ stored.messages.splice(
7739
+ lastIndex < 0 ? stored.messages.length : lastIndex + 1,
7740
+ 0,
7741
+ cloneSerializable(terminal.assistantMessage),
7742
+ );
7743
+ }
7441
7744
  }
7442
7745
 
7443
7746
  private emitTerminalProjection(run: IActiveRun, terminal: IFlexTerminalProjection): void {
@@ -7473,6 +7776,7 @@ export class FlexHarness<TScope = unknown> {
7473
7776
  const status: IFlexRunFinishedEvent['status'] = error
7474
7777
  ? cancelled ? 'cancelled' : 'failed'
7475
7778
  : 'completed';
7779
+ const steering = this.closeRunSteering(run.steering);
7476
7780
  this.emitEvent(run.scopeId, run.stored.session, {
7477
7781
  type: 'run.finished',
7478
7782
  runId: run.runId,
@@ -7480,6 +7784,8 @@ export class FlexHarness<TScope = unknown> {
7480
7784
  status,
7481
7785
  message: publicSnapshot(message),
7482
7786
  ...(error === undefined ? {} : { error: errorToInfo(error) }),
7787
+ keptInContext: steering.keptInContext,
7788
+ unappliedSteerIds: [...steering.unappliedSteerIds],
7483
7789
  });
7484
7790
  }
7485
7791
 
@@ -8156,6 +8462,8 @@ export class FlexHarness<TScope = unknown> {
8156
8462
  context?: IFlexAgentContextInvocation<TScope>,
8157
8463
  ): void {
8158
8464
  const invocationContext = context ?? this.createRunCompactorContext(run);
8465
+ // A stopped run takes no further steers; the ones it has not taken in are reported unapplied.
8466
+ run.steering.input.close();
8159
8467
  if (run.internalFailure === undefined && run.ownerCancellation === undefined) {
8160
8468
  run.ownerCancellation = reason;
8161
8469
  run.deferredCompactorContext = invocationContext;
@@ -8165,6 +8473,8 @@ export class FlexHarness<TScope = unknown> {
8165
8473
  (run.deferredCompactorContext ?? invocationContext) as IFlexAgentContextInvocation<TScope>,
8166
8474
  () => {
8167
8475
  this.rejectRunPermissions(run.state, run, reason);
8476
+ // The owner stops the generation: what its model produced stays in the model context.
8477
+ if (run.ownerCancellation === reason && !run.stop.signal.aborted) run.stop.abort(reason);
8168
8478
  if (!run.controller.signal.aborted) run.controller.abort(reason);
8169
8479
  if (run.phase === 'admitting') return;
8170
8480
  if (run.scheduleKey) {
@@ -8182,6 +8492,7 @@ export class FlexHarness<TScope = unknown> {
8182
8492
  context?: IFlexAgentContextInvocation<TScope>,
8183
8493
  ): void {
8184
8494
  const invocationContext = context ?? this.createRunCompactorContext(run);
8495
+ run.steering.input.close();
8185
8496
  if (run.internalFailure === undefined) {
8186
8497
  run.internalFailure = error ?? new Error('Internal FlexHarness run failure.');
8187
8498
  run.deferredCompactorContext = invocationContext;
@@ -9265,8 +9576,12 @@ export class FlexHarness<TScope = unknown> {
9265
9576
  status: 'completed' | 'failed' | 'cancelled',
9266
9577
  ): IFlexTerminalProjection {
9267
9578
  const timestamp = new Date().toISOString();
9268
- const sourceUser = stage?.userMessage ?? visible.find((message) => message.role === 'user');
9269
- const sourceAssistant = stage?.assistantMessage ?? visible.find((message) => message.role === 'assistant');
9579
+ // A steered run projects its prompt first and its steers after it; its last assistant
9580
+ // message is the one that was still answering.
9581
+ const sourceUser = stage?.userMessage
9582
+ ?? visible.find((message) => message.role === 'user' && message.steerId === undefined);
9583
+ const sourceAssistant = stage?.assistantMessage
9584
+ ?? [...visible].reverse().find((message) => message.role === 'assistant');
9270
9585
  const userMessage: IFlexMessage = cloneSerializable(sourceUser ?? {
9271
9586
  messageId: plugins.crypto.randomUUID(),
9272
9587
  sessionId,
package/ts/errors.ts CHANGED
@@ -44,6 +44,30 @@ export class FlexHarnessSessionBusyError extends FlexHarnessError {
44
44
  }
45
45
  }
46
46
 
47
+ export type TFlexSteerRejection = 'not-running' | 'duplicate';
48
+
49
+ /**
50
+ * The run refused a steer. `not-running`: the prompt has no run that takes input, because it
51
+ * has not started or its run is ending; the steer belongs to the next turn. `duplicate`: the run
52
+ * already accepted a steer with this id.
53
+ */
54
+ export class FlexHarnessSteerRejectedError extends FlexHarnessError {
55
+ public readonly reason: TFlexSteerRejection;
56
+ public readonly steerId: string;
57
+
58
+ constructor(reason: TFlexSteerRejection, steerId: string) {
59
+ super(
60
+ reason === 'duplicate' ? 'FLEX_STEER_DUPLICATE' : 'FLEX_STEER_NOT_RUNNING',
61
+ reason === 'duplicate'
62
+ ? `Steer "${steerId}" was already accepted.`
63
+ : `Steer "${steerId}" has no running turn to join.`,
64
+ );
65
+ this.name = 'FlexHarnessSteerRejectedError';
66
+ this.reason = reason;
67
+ this.steerId = steerId;
68
+ }
69
+ }
70
+
47
71
  /** An operation would change the history or model context of an imported, read-only session. */
48
72
  export class FlexHarnessImportedSessionError extends FlexHarnessError {
49
73
  public readonly sessionId: string;
package/ts/interfaces.ts CHANGED
@@ -141,6 +141,8 @@ export interface IFlexMessage {
141
141
  error?: string;
142
142
  /** The model limit that failed this run, next to its error. */
143
143
  limit?: TFlexModelLimit;
144
+ /** On a user message that steered its running run: the caller's steer identity. */
145
+ steerId?: string;
144
146
  }
145
147
 
146
148
  export interface IFlexSessionActivity {
@@ -436,17 +438,46 @@ export interface IFlexPromptQueueEntry {
436
438
  startedAt?: string;
437
439
  finishedAt?: string;
438
440
  error?: IFlexErrorInfo;
441
+ /**
442
+ * On a finished entry: whether its prompt, the steers its run took in and what its model
443
+ * produced stay in the model context of later turns. True for a completed run and a run
444
+ * stopped after its model produced output; false for a failed run, a run stopped before any
445
+ * model output and a prompt that never ran. Send the prompt again to have it answered.
446
+ */
447
+ keptInContext?: boolean;
448
+ /**
449
+ * On a finished entry whose run left steers unapplied: the steers it accepted that the model
450
+ * context of later turns does not hold, in the order they arrived. The caller decides what
451
+ * becomes of them.
452
+ */
453
+ unappliedSteerIds?: string[];
439
454
  }
440
455
 
441
456
  export interface IFlexPromptResult {
442
457
  runId: string;
443
458
  sessionId: string;
444
459
  userMessage: IFlexMessage;
460
+ /** The run's last assistant message; a steered run completed earlier ones as it took steers in. */
445
461
  assistantMessage: IFlexMessage;
446
462
  model: IFlexModelIdentity;
447
463
  usage: IFlexUsage;
448
464
  finishReason: string;
449
465
  steps: number;
466
+ /** Steers the run accepted but never took in, in the order they arrived. */
467
+ unappliedSteerIds: string[];
468
+ }
469
+
470
+ /** A message for the running run of a session, taken in at the run's next step boundary. */
471
+ export interface IFlexSteerInput {
472
+ /** Caller-chosen identity, unique among the steers of the run. */
473
+ steerId: string;
474
+ prompt: TFlexPrompt;
475
+ }
476
+
477
+ export interface IFlexSteerAdmission {
478
+ steerId: string;
479
+ queueId: string;
480
+ runId: string;
450
481
  }
451
482
 
452
483
  export interface IFlexPromptQueueAdmission {
@@ -1455,6 +1486,36 @@ export interface IFlexRunFinishedEvent extends IFlexEventBase {
1455
1486
  readonly status: 'completed' | 'failed' | 'cancelled';
1456
1487
  readonly message: IFlexMessage;
1457
1488
  readonly error?: IFlexErrorInfo;
1489
+ /**
1490
+ * Whether the run's prompt, the steers it took in and what its model produced stay in the
1491
+ * model context of later turns. True for a completed run and a run stopped after its model
1492
+ * produced output; false for a failed run and a run stopped before any model output, whose
1493
+ * prompt the caller sends again to have it answered.
1494
+ */
1495
+ readonly keptInContext: boolean;
1496
+ /**
1497
+ * Steers the run accepted that the model context of later turns does not hold, in the order
1498
+ * they arrived: the ones it never took in, and, when the run is not kept in context, every
1499
+ * steer it accepted.
1500
+ */
1501
+ readonly unappliedSteerIds: readonly string[];
1502
+ }
1503
+
1504
+ /** The running run accepted a steer; it is taken in at the run's next step boundary. */
1505
+ export interface IFlexSteerAcceptedEvent extends IFlexEventBase {
1506
+ readonly type: 'prompt.steer.accepted';
1507
+ readonly queueId: string;
1508
+ readonly runId: string;
1509
+ readonly steerId: string;
1510
+ }
1511
+
1512
+ /** The run took a steer in as the user message `messageId`, before its next model call. */
1513
+ export interface IFlexSteerAppliedEvent extends IFlexEventBase {
1514
+ readonly type: 'prompt.steer.applied';
1515
+ readonly queueId: string;
1516
+ readonly runId: string;
1517
+ readonly steerId: string;
1518
+ readonly messageId: string;
1458
1519
  }
1459
1520
 
1460
1521
  /** The run waits `delayMs` before retrying a rate-limited or unavailable model call. */
@@ -1513,6 +1574,8 @@ export type TFlexHarnessEvent =
1513
1574
  | IFlexRunRetryingEvent
1514
1575
  | TFlexModelUsageEvent
1515
1576
  | IFlexPromptQueueEvent
1577
+ | IFlexSteerAcceptedEvent
1578
+ | IFlexSteerAppliedEvent
1516
1579
  | IFlexSessionHistoryChangedEvent;
1517
1580
 
1518
1581
  export type TFlexHarnessEventListener = (event: TFlexHarnessEvent) => void;
package/ts/plugins.ts CHANGED
@@ -9,12 +9,15 @@ export { AsyncLocalStorage, crypto, fs, path };
9
9
  // @push.rocks scope
10
10
  import {
11
11
  AgentEventStoreConflictError,
12
+ AgentPendingInputQueue,
13
+ AgentPendingInputRejectedError,
12
14
  AgentSession,
13
15
  buildModelMessages,
14
16
  ToolJobStoreConflictError,
15
17
  createAgentEvent,
16
18
  filterModelVisibleAgentEvents,
17
19
  getAgentGenerationTransactions,
20
+ isGenerationInModelContext,
18
21
  modelMessagesToAgentEvents,
19
22
  tool,
20
23
  validateAgentEventArchiveV2,
@@ -24,12 +27,15 @@ import {
24
27
 
25
28
  export {
26
29
  AgentEventStoreConflictError,
30
+ AgentPendingInputQueue,
31
+ AgentPendingInputRejectedError,
27
32
  AgentSession,
28
33
  buildModelMessages,
29
34
  ToolJobStoreConflictError,
30
35
  createAgentEvent,
31
36
  filterModelVisibleAgentEvents,
32
37
  getAgentGenerationTransactions,
38
+ isGenerationInModelContext,
33
39
  modelMessagesToAgentEvents,
34
40
  tool,
35
41
  validateAgentEventArchiveV2,
@@ -37,6 +43,7 @@ export {
37
43
  z,
38
44
  };
39
45
  export type {
46
+ IAgentAppliedInput,
40
47
  IAgentEventArchiveV2,
41
48
  IAgentContextCompactionOptions,
42
49
  IAgentEventSnapshotV2,