@sema-agent/core 5.60.0 → 5.61.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/dist/agents/subagent.d.ts +4 -2
  3. package/dist/agents/subagent.js +9 -9
  4. package/dist/core/checkpoint-store.d.ts +56 -6
  5. package/dist/core/governance-codes.d.ts +1 -1
  6. package/dist/core/governance-codes.js +2 -2
  7. package/dist/core/mcp.d.ts +4 -5
  8. package/dist/core/memory-engine/consolidation-driver.d.ts +19 -1
  9. package/dist/core/memory-engine/consolidation-driver.js +75 -3
  10. package/dist/core/memory-engine/consolidation.d.ts +52 -5
  11. package/dist/core/memory-engine/consolidation.js +3 -1
  12. package/dist/core/memory-engine/distiller.d.ts +89 -1
  13. package/dist/core/memory-engine/distiller.js +94 -5
  14. package/dist/core/memory-engine/engine.d.ts +13 -6
  15. package/dist/core/memory-engine/engine.js +51 -8
  16. package/dist/core/memory-engine/index.d.ts +1 -1
  17. package/dist/core/memory-engine/index.js +1 -1
  18. package/dist/core/runner/prepare-task.d.ts +6 -3
  19. package/dist/core/runner/prepare-task.js +1 -0
  20. package/dist/core/runner/runtask.js +113 -31
  21. package/dist/core/task-notification.d.ts +50 -23
  22. package/dist/core/task-notification.js +20 -4
  23. package/dist/core/tool-policy.d.ts +8 -2
  24. package/dist/core/types.d.ts +76 -19
  25. package/dist/engine/harness/agent-harness.d.ts +58 -2
  26. package/dist/engine/harness/agent-harness.js +115 -5
  27. package/dist/engine/loop/agent-loop.js +153 -15
  28. package/dist/engine/loop/types.d.ts +32 -0
  29. package/dist/index.d.ts +3 -3
  30. package/dist/index.js +3 -3
  31. package/dist/orchestration/run-workflow-tool.d.ts +7 -2
  32. package/dist/orchestration/run-workflow-tool.js +1 -1
  33. package/dist/tools/monitor.d.ts +3 -3
  34. package/dist/tools/monitor.js +1 -1
  35. package/package.json +1 -1
  36. package/test/export-surface.snapshot.json +11 -1
@@ -4177,6 +4177,12 @@ export type TaskEvent = ({
4177
4177
  */
4178
4178
  type: "task_notification";
4179
4179
  notification: TaskNotificationPayload;
4180
+ /** design/373 §3.6 — the frame's injection tier as DELIVERED (additive; consumers tolerate
4181
+ * absence): on the live lane, the tier the routing used (`next`/`now` → steer lane, `later`
4182
+ * → followUp lane); on the turn-open redelivery lane, the tier the park CARRIED. ABSENT is a
4183
+ * fact, not a default: a pre-373 producer event, or a parked record whose tier was not
4184
+ * carried (the terminal sweep's re-pend) — never read absence as `"later"`. */
4185
+ priority?: import("./task-notification.js").SystemInjectionPriority;
4180
4186
  } & TaskEventIdentity) | ({
4181
4187
  /**
4182
4188
  * design/99 MF-10 — a SUBAGENT PROGRESS TICK: the delegated sub-run's usage ACCRUING
@@ -4380,11 +4386,43 @@ export interface TaskStream extends AsyncIterable<TaskEvent> {
4380
4386
  * into this run's resume prompt) is NOT in it and WOULD inject again. A deployment that needs
4381
4387
  * cross-leg or cross-process idempotency owns that half (its own key ledger), the same division
4382
4388
  * of labor `notify`'s park-window dedup states.
4389
+ *
4390
+ * design/373 (additive) — `priority` is the steer face's injection tier, default `"next"`
4391
+ * (= the pre-373 behavior, byte-identical):
4392
+ * - `"next"` — the running turn's next boundary (unchanged);
4393
+ * - `"later"` — never folded into work in progress: delivered at the run's natural
4394
+ * would-otherwise-stop seat (its own closing turn); a run that ends before that seat re-parks
4395
+ * the input via the existing durable/undrained lanes (receipt semantics unchanged — "queued"
4396
+ * still means accepted-at-this-moment, never "consumed"); a `"later"` steer rides the followUp
4397
+ * lane, so its stranded account is `task.user_followup_undrained` (the accounts are LANE-keyed
4398
+ * — same two-key split the settled frame uses);
4399
+ * - `"now"` — `"next"`'s delivery guarantee PLUS the boundary is manufactured early: the frame
4400
+ * takes the queue's class head and, when a turn is in flight, the engine CUTS that turn at a
4401
+ * reconciled boundary (finished tool calls keep their real results; never-started ones settle
4402
+ * as paired interrupted results; the CC-verbatim interruption marker lands) and the run
4403
+ * CONTINUES with this input — never the run-level `interrupt()`. Best-effort accelerator:
4404
+ * with nothing in flight (idle tail, between turns) delivery is exactly `"next"`'s. Every
4405
+ * real cut is announced (`task.turn_interrupted` notice) and counts toward `maxTurns` —
4406
+ * deliberately (the hard cap IS the interrupt storm's bound), which means a `now` issued at
4407
+ * the run's FINAL allowed turn spends the remaining budget on the cut and the run then ends
4408
+ * at the limit before the steer's own turn: the input takes the terminal lanes like any
4409
+ * accepted-but-undelivered steer (durable park when a resource-suspend seat is eligible —
4410
+ * redeemed on resume; otherwise the loud `task.user_steer_undrained` account). Same for the
4411
+ * other terminal gates (budget/walltime) firing at the cut's own `turn_end`. The receipt
4412
+ * never promised consumption; no budget is reserved for the interruptor.
4413
+ * ⚠️ POWER FACE (§5-4b): passing `priority` through to a third-party caller GRANTS it the power
4414
+ * to cut this run's in-flight turns (cancelling that turn's in-flight tool work). A deployment
4415
+ * relaying steer must gate this option at its own face — relay IS authorization.
4416
+ * An unknown value throws typed `steering.invalid_content` (bad-value loudness). The replay
4417
+ * identity (`inputId` idempotency) includes the NORMALIZED tier: the same text replayed under
4418
+ * the same id at a different tier refuses `steering.duplicate_input_id` — an "idempotent
4419
+ * success" that silently skipped the interrupt would be a disposition lie.
4383
4420
  */
4384
4421
  steer(text: string, options?: {
4385
4422
  trusted?: boolean;
4386
4423
  actor?: ActorAssertion;
4387
4424
  inputId?: string;
4425
+ priority?: import("./task-notification.js").SystemInjectionPriority;
4388
4426
  }): Promise<void>;
4389
4427
  /**
4390
4428
  * design/144 §2 — inject an EXTERNAL structured event into this run's task-notification lane, as a
@@ -4392,19 +4430,37 @@ export interface TaskStream extends AsyncIterable<TaskEvent> {
4392
4430
  * the payload (`task_type` is cast to `"external"` — a caller can never impersonate an internal lane)
4393
4431
  * and every field is sanitized as UNTRUSTED data on the model face. Delivery rides the EXISTING
4394
4432
  * notification machinery, so the semantics are identical to internal producers:
4395
- * - every priority is injected at the NEXT turn boundary (steer lane, mid-work included; ruled
4396
- * 2026-08-05 the retired `"later"`→followUp mapping starved a busy run of its completions).
4397
- * Delivery order is ARRIVAL order; consecutive buffered notifications drain as one boundary
4398
- * batch. `priority` still rides the park/uplink path for torn-down lanes;
4433
+ * - design/373 the injection LADDER is live on this lane (BREAKING vs the flat era, named):
4434
+ * `"next"` = the running turn's next boundary (mid-work included the previous behavior of
4435
+ * every frame, now this tier's); `"later"` = never folded into work in progress — delivered at
4436
+ * the run's natural would-otherwise-stop seat (its own closing turn), and a run that ends
4437
+ * before that seat parks the frame per session for the NEXT run's turn-open (delivery then
4438
+ * depends on a next run happening — a recorded structural honesty note, this engine owns no
4439
+ * idle process); `"now"` is REFUSED typed (`notify.invalid_priority`) — turn-interrupt
4440
+ * authority belongs to the caller-provenance steer face, never to the notification lane
4441
+ * (callers wanting earliest-boundary delivery say `"next"`). ⚠️ The parameterless DEFAULT is
4442
+ * `"later"` (the CC pending-notification default): an omitted priority moved from
4443
+ * next-boundary to the closing seat with this design — callers that need boundary delivery
4444
+ * must say `"next"` explicitly. NOTE for the flat era's callers: `"later"` was silently inert
4445
+ * then (delivered at the boundary with no runtime signal saying otherwise) — the version is
4446
+ * the ONLY discriminator of which behavior a build has. Delivery order within a tier is
4447
+ * ARRIVAL order; consecutive buffered notifications drain as one boundary batch. `priority`
4448
+ * rides the park path (pend records carry it; the turn-open batch is priority-major) and the
4449
+ * uplink path;
4399
4450
  * - duplicate events dedup on `task_id:status[:seq]` — pass a fresh `seq` per repeat event. The
4400
4451
  * external lane keys in its OWN dedup domain (design/144 X1): an external event can never collide
4401
- * with (or pre-occupy) an internal lane's key, and vice versa;
4452
+ * with (or pre-occupy) an internal lane's key, and vice versa. The key deliberately excludes
4453
+ * `priority` (tier = delivery metadata, not event identity): a repeat under an in-flight key
4454
+ * folds WHATEVER tier it names — the standing entry keeps the FIRST accept's tier — so
4455
+ * re-tiering requires a fresh `seq` (a new occurrence), never a same-key resend;
4402
4456
  * - a notify landing AFTER the run ended PARKS per session and the session's NEXT run delivers it
4403
4457
  * (unlike {@link steer}, a finished task does not reject — the park lane is the contract). The park
4404
4458
  * leg dedups on the same key (design/144 X4) with an HONEST WINDOW LIMIT: the dedup set spans ONE
4405
4459
  * parked batch — it does not reach back across the live-queue/park boundary, and it resets when the
4406
4460
  * next run drains the batch (a repeat sent after that drain is deliverable again);
4407
- * Throws typed `notify.invalid_payload` on a malformed input and `notify.not_running` when the run
4461
+ * Throws typed `notify.invalid_payload` on a malformed input, `notify.invalid_priority` on
4462
+ * `priority: "now"` (see above — a KNOWN value this lane refuses, distinct from an unknown one),
4463
+ * and `notify.not_running` when the run
4408
4464
  * never built its notification lane (prepare failed). Everything upstream of the event reaching this
4409
4465
  * process — transport, webhook/daemon wiring, inbound authn, durable parking for idle sessions — is
4410
4466
  * the DEPLOYMENT half (design/43: zero daemon semantics in core).
@@ -4992,19 +5048,20 @@ export interface EngineNotice {
4992
5048
  * — what is announced is the remainder that could NOT be carried, so a fully-migrated park says
4993
5049
  * nothing at all and a queue-full / no-longer-pending row still says exactly what was lost.
4994
5050
  *
4995
- * - `"task.injection_priority_unimplemented"` (#389 伴生, D-3) — a notification was injected with
4996
- * `priority: "now"`. The three values are CC's names (`now`/`next`/`later`) but this engine
4997
- * delivers all three identically at the next turn boundary: there is no arm that aborts the
4998
- * running turn, so a caller writing `"now"` and expecting an interruption gets a plain queued
4999
- * delivery. The frame IS delivered (this is disclosure, not a refusal dropping an accepted
5000
- * notification would be the worse error); what is announced is that the knob's promise is not
5001
- * honored, so an operator can stop building on it. Once per run (the injection funnel is a
5002
- * single site, and a busy lane must not narrate the same gap once per frame);
5003
- * `detail: { priority, taskId?, sessionId }` — `sessionId` (#433, ALWAYS present: the funnel
5004
- * sits past prepare, which owns a session unconditionally) is a correlation key only, the
5005
- * audience stays `"operator"` (the party building on the unhonored knob is whoever called
5006
- * notify, not the end user). An UNKNOWN priority value is a different fact with a
5007
- * different posture `TaskStream.notify` refuses it typed (`notify.invalid_payload`).
5051
+ * - `"task.turn_interrupted"` (design/373) — a caller-provenance steer with `priority: "now"`
5052
+ * ACTUALLY CUT the running turn: the in-flight provider stream / tool batch was aborted at a
5053
+ * manufactured boundary (finished tool calls keep their real results, never-started ones
5054
+ * settle as paired interrupted results, the interruption marker lands) and the run CONTINUES
5055
+ * with the steer at the queue head. One notice per REAL cut (a `now` that found nothing in
5056
+ * flight, or whose frame already rode the imminent boundary, announces nothing no false
5057
+ * interrupt claims); every cut turn counts toward `maxTurns`, so an interrupt storm's cost is
5058
+ * bounded and each of its cuts is on the record. `detail: { inputId, sessionId, actorId?,
5059
+ * taskId? }` — `inputId` is the steer's own correlation key (the `human_input` frame's id),
5060
+ * `actorId` the caller's asserted identity when one rode the steer. Audience `"user"`: the
5061
+ * person whose input forced the boundary is the one entitled to see that it landed.
5062
+ * RETIRED here (BREAKING, named): `"task.injection_priority_unimplemented"` the ladder is
5063
+ * implemented, so the unhonored-knob disclosure it carried has no referent; consumers must
5064
+ * judge ladder support by VERSION, never by that code's absence.
5008
5065
  *
5009
5066
  * - `"memory.session_polluted"` (design/178 §3, #324a; message mode-aware since design/336) —
5010
5067
  * this session's memory crossed into the one-way externally-exposed state (a tool classified
@@ -1,4 +1,4 @@
1
- import type { ActorAssertion, AssistantMessage, ImageContent, Model } from "../llm/index.js";
1
+ import type { ActorAssertion, AssistantMessage, ImageContent, Model, UserMessage } from "../llm/index.js";
2
2
  import type { AgentMessage, AgentTool, LoopMalformedToolUseRecovery, LoopThinkingOnlyRecovery, LoopTruncatedOutputRecovery, QueueMode, ThinkingLevel } from "../loop/types.js";
3
3
  import { type EngineSegment } from "../../core/untrusted-text.js";
4
4
  import type { AbortResult, AgentHarnessEvent, AgentHarnessEventResultMap, AgentHarnessOptions, AgentHarnessOwnEvent, AgentHarnessResources, AgentHarnessStreamOptions, ExecutionEnv, PromptTemplate, Skill } from "./types.js";
@@ -47,6 +47,22 @@ export interface UserMessageProvenance {
47
47
  * the parked-steer queue needs (pre-framing text + trust + inputId), which only the minting seam
48
48
  * knows — hence opaque. */
49
49
  parkRecord?: unknown;
50
+ /** design/373 — an IMMEDIATE-class ("now") frame: the steer-lane enqueue inserts it ahead of the
51
+ * first non-immediate frame (FIFO among immediates — the dequeue-priority form), the loop's
52
+ * pre-request re-check sweeps it into the imminent turn, and {@link AgentHarness.interruptTurn}
53
+ * may force the boundary for it. Class metadata only (a sidecar, judged at the mint) — it never
54
+ * rides the message or the wire, and it grants NOTHING by itself: the interrupt is a separate,
55
+ * caller-provenance verb. */
56
+ immediate?: true;
57
+ /** design/373 (#445 saturation prerequisite) — terminal-preference at the engine-note backlog
58
+ * cap: when the target queue's engine-note count is AT the cap, a frame carrying this mark
59
+ * displaces the OLDEST non-preferred engine-note frame instead of being refused — the displaced
60
+ * payload is handed to the undrained sink (the runner's lossless per-session park lane), so a
61
+ * watcher's event storm can delay its own batches but never crowd a terminal completion out of
62
+ * the run that is waiting on it (the pend store's terminal-preferred eviction, mirrored at the
63
+ * queue mouth). Only meaningful WITH `enginePayload`; a preferred frame among only preferred
64
+ * frames still gets the cap refusal (nothing displaceable). */
65
+ capPreferred?: true;
50
66
  }
51
67
  /**
52
68
  * Harness-level recovery wiring (design/118 ④b/⑤). The loop's prompt-too-long seam wants a
@@ -190,6 +206,9 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
190
206
  private loopRecovery?;
191
207
  /** roadmap #5 (CC Stop hook): the runner-wired stop gate — see {@link setStopGate}. */
192
208
  private stopGate?;
209
+ /** design/373 — the loop-published turn-scoped AbortController (live exactly while a main turn's
210
+ * work phase is in flight; `undefined` between turns). Read by {@link interruptTurn} only. */
211
+ private turnInterruptSeat?;
193
212
  private handlers;
194
213
  constructor(options: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>);
195
214
  private getHandlers;
@@ -264,12 +283,49 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
264
283
  * engine lanes each check the halt fact before injecting, so this arm exists for the lane that
265
284
  * does not — including the one not written yet. */
266
285
  private refuseHeldEngineInjection;
286
+ /** design/373 — returns the minted queue frame (or `undefined` on the empty-injection no-op), so
287
+ * a caller-provenance immediate steer can hand the SAME frame to {@link interruptTurn} (the
288
+ * consumption guard is identity-keyed). Additive: every existing caller awaited `void`. */
267
289
  steer(text: string, options?: {
268
290
  images?: ImageContent[];
269
- } & UserMessageProvenance): Promise<void>;
291
+ } & UserMessageProvenance): Promise<UserMessage | undefined>;
270
292
  followUp(text: string, options?: {
271
293
  images?: ImageContent[];
272
294
  } & UserMessageProvenance): Promise<void>;
295
+ /**
296
+ * design/373 §3.4 — force the running turn's boundary FOR a queued immediate-class frame:
297
+ * abort the CURRENT main turn (provider stream + tool batch — never the run, never boundary
298
+ * housekeeping), whose loop-side settlement reconciles the cut and continues the run with the
299
+ * frame at the queue head. Best-effort accelerator by contract: `false` means no interruption
300
+ * happened AND none was needed — the frame is already consumed or already rides the imminent
301
+ * boundary — so delivery semantics are exactly the non-interrupting ones either way.
302
+ *
303
+ * Guards, in order:
304
+ * - an unwinding run (`abort()` latched) owns its ending — never contest it;
305
+ * - CONSUMPTION guard (r4/R3-1): the frame must still sit in the steer queue. Drained/consumed ⇒
306
+ * it is already part of a turn's input — aborting THAT turn would cut the very delivery this
307
+ * verb exists to speed up. Same-tick check-then-abort (single-threaded: no window);
308
+ * - seat liveness: no published turn (idle tail / between turns / boundary housekeeping) ⇒ no-op,
309
+ * the frame rides the next boundary; an already-fired seat ⇒ idempotent no-op (one boundary per
310
+ * turn — later immediates join the same boundary batch).
311
+ */
312
+ interruptTurn(frame: AgentMessage): boolean;
313
+ /**
314
+ * design/373 §3.3 (the final-commit-point double check) — how many queued injection frames a
315
+ * boundary drain could deliver RIGHT NOW: both lanes, minus frames the current state refuses to
316
+ * drain (an unwinding run drains nothing — those frames belong to the terminal account; a live
317
+ * human-halt hold skips engine-authored frames). Synchronous and side-effect-free by contract:
318
+ * the loop reads it between its last drain and the terminal commit, with zero awaits.
319
+ */
320
+ pendingInjectionCount(): number;
321
+ /** design/373 §3.4-3 — the pre-request re-check's drain half (see the loop-config seam): sweep
322
+ * queued immediate-class frames into the imminent turn's batch, merged at the class position
323
+ * (after immediates already pending, ahead of everything else — FIFO within the class holds
324
+ * across the drain/re-check split). Same booking discipline as {@link drainQueuedMessages}
325
+ * (in-flight account, queue_update broadcast, consumption sink, throw-rollback); the hold skips
326
+ * engine-authored immediates exactly as the boundary drain would (a theoretical lane today —
327
+ * no engine mint issues immediates — held by construction rather than by remembering). */
328
+ private absorbImmediateSteering;
273
329
  nextTurn(text: string, options?: {
274
330
  images?: ImageContent[];
275
331
  } & UserMessageProvenance): Promise<void>;
@@ -38,6 +38,8 @@ const engineNotePayloads = new WeakMap();
38
38
  const engineAuthoredInjections = new WeakSet();
39
39
  const userInputParkRecords = new WeakMap();
40
40
  const drainedFromLane = new WeakMap();
41
+ const immediateClassInjections = new WeakSet();
42
+ const capPreferredInjections = new WeakSet();
41
43
  function isEngineAuthoredInjection(options) {
42
44
  if (options === undefined)
43
45
  return false;
@@ -257,6 +259,7 @@ export class AgentHarness {
257
259
  nextTurnQueue = [];
258
260
  loopRecovery;
259
261
  stopGate;
262
+ turnInterruptSeat;
260
263
  handlers = new Map();
261
264
  constructor(options) {
262
265
  this.env = options.env;
@@ -598,6 +601,11 @@ export class AgentHarness {
598
601
  };
599
602
  },
600
603
  getSteeringMessages: async () => this.drainQueuedMessages(this.steerQueue, this.steeringQueueMode),
604
+ publishTurnInterruptSeat: (seat) => {
605
+ this.turnInterruptSeat = seat;
606
+ },
607
+ recheckImmediateInjections: async (pending) => this.absorbImmediateSteering(pending),
608
+ pendingInjectionCount: () => this.pendingInjectionCount(),
601
609
  getFollowUpMessages: async () => {
602
610
  const queued = await this.drainQueuedMessages(this.followUpQueue, this.followUpQueueMode);
603
611
  if (queued.length > 0)
@@ -841,9 +849,16 @@ export class AgentHarness {
841
849
  }
842
850
  async enqueueInjection(queue, text, options) {
843
851
  if (AgentHarness.emptyInjection(text, options))
844
- return;
852
+ return undefined;
853
+ let displaced;
845
854
  if (options?.enginePayload !== undefined && queue.filter((q) => engineNotePayloads.has(q)).length >= ENGINE_NOTE_STEER_BACKLOG_CAP) {
846
- throw new AgentHarnessError("invalid_state", `engine-note backlog at cap (${ENGINE_NOTE_STEER_BACKLOG_CAP}) — park the payload for the session's next run`);
855
+ const victimIdx = options.capPreferred === true
856
+ ? queue.findIndex((q) => engineNotePayloads.has(q) && !capPreferredInjections.has(q))
857
+ : -1;
858
+ if (victimIdx === -1) {
859
+ throw new AgentHarnessError("invalid_state", `engine-note backlog at cap (${ENGINE_NOTE_STEER_BACKLOG_CAP}) — park the payload for the session's next run`);
860
+ }
861
+ displaced = { frame: queue.splice(victimIdx, 1)[0], at: victimIdx };
847
862
  }
848
863
  const m = createUserMessage(text, options?.images, options);
849
864
  if (options?.enginePayload !== undefined)
@@ -852,8 +867,43 @@ export class AgentHarness {
852
867
  engineAuthoredInjections.add(m);
853
868
  if (options?.parkRecord !== undefined)
854
869
  userInputParkRecords.set(m, options.parkRecord);
855
- queue.push(m);
856
- await this.emitQueueUpdate();
870
+ if (options?.immediate === true)
871
+ immediateClassInjections.add(m);
872
+ if (options?.capPreferred === true && options?.enginePayload !== undefined)
873
+ capPreferredInjections.add(m);
874
+ if (options?.immediate === true && queue === this.steerQueue) {
875
+ let at = 0;
876
+ while (at < queue.length && immediateClassInjections.has(queue[at]))
877
+ at++;
878
+ queue.splice(at, 0, m);
879
+ }
880
+ else {
881
+ queue.push(m);
882
+ }
883
+ try {
884
+ await this.emitQueueUpdate();
885
+ }
886
+ catch (error) {
887
+ const at = queue.indexOf(m);
888
+ if (at !== -1) {
889
+ queue.splice(at, 1);
890
+ if (displaced !== undefined)
891
+ queue.splice(Math.min(displaced.at, queue.length), 0, displaced.frame);
892
+ throw error;
893
+ }
894
+ }
895
+ if (displaced !== undefined) {
896
+ const victimPayload = engineNotePayloads.get(displaced.frame);
897
+ engineNotePayloads.delete(displaced.frame);
898
+ if (victimPayload !== undefined && this.onUndrainedEngineNotes) {
899
+ try {
900
+ this.onUndrainedEngineNotes([victimPayload]);
901
+ }
902
+ catch {
903
+ }
904
+ }
905
+ }
906
+ return m;
857
907
  }
858
908
  refuseHeldEngineInjection(options) {
859
909
  if (isEngineAuthoredInjection(options) && this.engineInjectionsHeld?.() === true) {
@@ -865,7 +915,7 @@ export class AgentHarness {
865
915
  throw new AgentHarnessError("invalid_state", "Cannot steer while idle");
866
916
  }
867
917
  this.refuseHeldEngineInjection(options);
868
- await this.enqueueInjection(this.steerQueue, text, options);
918
+ return await this.enqueueInjection(this.steerQueue, text, options);
869
919
  }
870
920
  async followUp(text, options) {
871
921
  if (this.phase === "idle") {
@@ -874,6 +924,66 @@ export class AgentHarness {
874
924
  this.refuseHeldEngineInjection(options);
875
925
  await this.enqueueInjection(this.followUpQueue, text, options);
876
926
  }
927
+ interruptTurn(frame) {
928
+ if (this.aborting)
929
+ return false;
930
+ if (!this.steerQueue.includes(frame))
931
+ return false;
932
+ const seat = this.turnInterruptSeat;
933
+ if (seat === undefined || seat.signal.aborted)
934
+ return false;
935
+ seat.abort();
936
+ return true;
937
+ }
938
+ pendingInjectionCount() {
939
+ if (this.aborting)
940
+ return 0;
941
+ const held = this.engineInjectionsHeld?.() === true;
942
+ const drainable = (q) => held ? q.filter((m) => !engineAuthoredInjections.has(m)).length : q.length;
943
+ return drainable(this.steerQueue) + drainable(this.followUpQueue);
944
+ }
945
+ async absorbImmediateSteering(pending) {
946
+ if (this.aborting)
947
+ return pending;
948
+ const held = this.engineInjectionsHeld?.() === true;
949
+ const taken = [];
950
+ for (let i = 0; i < this.steerQueue.length; i++) {
951
+ const m = this.steerQueue[i];
952
+ if (!immediateClassInjections.has(m))
953
+ break;
954
+ if (held && engineAuthoredInjections.has(m))
955
+ continue;
956
+ taken.push(...this.steerQueue.splice(i, 1));
957
+ i--;
958
+ }
959
+ if (taken.length === 0)
960
+ return pending;
961
+ for (const m of taken)
962
+ drainedFromLane.set(m, "steer");
963
+ this.drainedPendingInjection.push(...taken);
964
+ try {
965
+ await this.emitQueueUpdate();
966
+ for (const m of taken) {
967
+ const payload = engineNotePayloads.get(m);
968
+ if (payload !== undefined) {
969
+ try {
970
+ this.onEngineNoteConsumed?.(payload);
971
+ }
972
+ catch {
973
+ }
974
+ }
975
+ }
976
+ }
977
+ catch (error) {
978
+ this.steerQueue.unshift(...taken);
979
+ this.unbookInFlight(taken);
980
+ throw normalizeHookError(error);
981
+ }
982
+ let at = 0;
983
+ while (at < pending.length && immediateClassInjections.has(pending[at]))
984
+ at++;
985
+ return [...pending.slice(0, at), ...taken, ...pending.slice(at)];
986
+ }
877
987
  async nextTurn(text, options) {
878
988
  await this.enqueueInjection(this.nextTurnQueue, text, options);
879
989
  }
@@ -1,5 +1,6 @@
1
1
  import { findToolByName, validateToolArguments } from "../llm/index.js";
2
2
  import { truncateError } from "../../core/tool-errors.js";
3
+ import { INTERRUPTED_BY_USER_FOR_TOOL_USE_MARKER, INTERRUPTED_BY_USER_MARKER, } from "../../core/session-reconcile.js";
3
4
  import { resolveAgentCoreStreamFn } from "./runtime-deps.js";
4
5
  function appendTextDeltaToAssistantMessage(message, contentIndex, delta) {
5
6
  const content = [...message.content];
@@ -158,6 +159,13 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
158
159
  if (!state.hasMoreToolCalls && state.pendingMessages.length === 0) {
159
160
  const followUpMessages = (await state.config.getFollowUpMessages?.()) || [];
160
161
  if (followUpMessages.length === 0) {
162
+ if ((state.config.pendingInjectionCount?.() ?? 0) > 0) {
163
+ state.pendingMessages = (await state.config.getSteeringMessages?.()) || [];
164
+ if (state.pendingMessages.length > 0) {
165
+ trace?.({ kind: "continue", reason: "steer_injected" });
166
+ }
167
+ continue;
168
+ }
161
169
  trace?.({ kind: "terminal", reason: "completed" });
162
170
  break;
163
171
  }
@@ -193,6 +201,38 @@ async function runSingleTurn(state, signal, emit, streamFn, runtime, trace) {
193
201
  else {
194
202
  state.firstTurn = false;
195
203
  }
204
+ const turnController = new AbortController();
205
+ const onRunAbort = () => turnController.abort();
206
+ if (signal !== undefined) {
207
+ if (signal.aborted)
208
+ turnController.abort();
209
+ else
210
+ signal.addEventListener("abort", onRunAbort, { once: true });
211
+ }
212
+ let seatLive = false;
213
+ const publishSeat = () => {
214
+ seatLive = true;
215
+ state.config.publishTurnInterruptSeat?.(turnController);
216
+ };
217
+ const retractSeat = () => {
218
+ if (!seatLive)
219
+ return;
220
+ seatLive = false;
221
+ state.config.publishTurnInterruptSeat?.(undefined);
222
+ };
223
+ try {
224
+ return await runTurnPhases(state, signal, turnController, publishSeat, retractSeat, emit, streamFn, runtime, trace);
225
+ }
226
+ finally {
227
+ retractSeat();
228
+ signal?.removeEventListener("abort", onRunAbort);
229
+ }
230
+ }
231
+ async function runTurnPhases(state, signal, turnController, publishSeat, retractSeat, emit, streamFn, runtime, trace) {
232
+ publishSeat();
233
+ if (state.config.recheckImmediateInjections) {
234
+ state.pendingMessages = await state.config.recheckImmediateInjections(state.pendingMessages);
235
+ }
196
236
  if (state.pendingMessages.length > 0) {
197
237
  for (const message of state.pendingMessages) {
198
238
  await emit({ type: "message_start", message });
@@ -201,7 +241,7 @@ async function runSingleTurn(state, signal, emit, streamFn, runtime, trace) {
201
241
  state.newMessages.push(message);
202
242
  }
203
243
  }
204
- let executor = new StreamToolExecutor(state.context, state.config, signal, emit);
244
+ let executor = new StreamToolExecutor(state.context, state.config, turnController.signal, emit);
205
245
  const staticReasoningCutDowngrade = state.config.recovery?.truncatedOutput !== undefined &&
206
246
  state.staticReasoningCutRecoveries < MAX_STATIC_REASONING_CUT_RECOVERIES;
207
247
  const ptl = state.config.recovery?.promptTooLong;
@@ -210,7 +250,7 @@ async function runSingleTurn(state, signal, emit, streamFn, runtime, trace) {
210
250
  let withhold = withholdEnabled ? createPtlWithholdBuffer(emit, ptlDetect) : undefined;
211
251
  let message;
212
252
  try {
213
- message = await streamAssistantResponse(state.context, state.config, signal, withhold?.sink ?? emit, streamFn, runtime, executor, staticReasoningCutDowngrade);
253
+ message = await streamAssistantResponse(state.context, state.config, turnController.signal, withhold?.sink ?? emit, streamFn, runtime, executor, staticReasoningCutDowngrade);
214
254
  }
215
255
  finally {
216
256
  await executor.settle();
@@ -246,9 +286,9 @@ async function runSingleTurn(state, signal, emit, streamFn, runtime, trace) {
246
286
  withhold = withholdEnabled ? createPtlWithholdBuffer(emit, ptlDetect) : undefined;
247
287
  state.context.messages = replaced;
248
288
  trace?.({ kind: "continue", reason: "reactive_compact_retry" });
249
- executor = new StreamToolExecutor(state.context, state.config, signal, emit);
289
+ executor = new StreamToolExecutor(state.context, state.config, turnController.signal, emit);
250
290
  try {
251
- message = await streamAssistantResponse(state.context, state.config, signal, withhold?.sink ?? emit, streamFn, runtime, executor, staticReasoningCutDowngrade);
291
+ message = await streamAssistantResponse(state.context, state.config, turnController.signal, withhold?.sink ?? emit, streamFn, runtime, executor, staticReasoningCutDowngrade);
252
292
  }
253
293
  finally {
254
294
  await executor.settle();
@@ -261,6 +301,10 @@ async function runSingleTurn(state, signal, emit, streamFn, runtime, trace) {
261
301
  state.newMessages.push(message);
262
302
  if (message.staticReasoningCut === true)
263
303
  state.staticReasoningCutRecoveries++;
304
+ if (message.stopReason === "aborted" && turnController.signal.aborted && !signal?.aborted) {
305
+ retractSeat();
306
+ return await settleInterruptedTurn(state, message, executor, [], signal, emit);
307
+ }
264
308
  {
265
309
  const degen = state.config.recovery?.degenerateOutput;
266
310
  if (degen &&
@@ -270,6 +314,7 @@ async function runSingleTurn(state, signal, emit, streamFn, runtime, trace) {
270
314
  executor.admittedCount === 0 &&
271
315
  message.content.every((b) => b.type !== "toolCall") &&
272
316
  state.degenerateContinues < (degen.maxContinues ?? 2)) {
317
+ retractSeat();
273
318
  state.degenerateContinues++;
274
319
  await emit({ type: "turn_end", message, toolResults: [] });
275
320
  const drained = (await state.config.getSteeringMessages?.()) || [];
@@ -280,7 +325,8 @@ async function runSingleTurn(state, signal, emit, streamFn, runtime, trace) {
280
325
  state.degenerateContinues = 0;
281
326
  }
282
327
  if (message.stopReason === "error" || message.stopReason === "aborted") {
283
- const errorFinalResults = await harvestExecutorOnErrorFinal(executor, state, message, signal, emit);
328
+ retractSeat();
329
+ const errorFinalResults = await harvestExecutorOnErrorFinal(executor, state, message, turnController.signal, emit);
284
330
  await emit({ type: "turn_end", message, toolResults: errorFinalResults });
285
331
  await emit({ type: "agent_end", messages: state.newMessages });
286
332
  return { kind: "terminal", reason: "assistant_error" };
@@ -289,7 +335,7 @@ async function runSingleTurn(state, signal, emit, streamFn, runtime, trace) {
289
335
  const toolResults = [];
290
336
  state.hasMoreToolCalls = false;
291
337
  if (toolCalls.length > 0) {
292
- const executedToolBatch = await executeToolCalls(state.context, message, state.config, signal, emit, executor);
338
+ const executedToolBatch = await executeToolCalls(state.context, message, state.config, turnController.signal, emit, executor);
293
339
  toolResults.push(...executedToolBatch.messages);
294
340
  state.hasMoreToolCalls = !executedToolBatch.terminate;
295
341
  for (const result of toolResults) {
@@ -303,6 +349,10 @@ async function runSingleTurn(state, signal, emit, streamFn, runtime, trace) {
303
349
  await closeOrphanedStreamEntry(orphan, emit);
304
350
  }
305
351
  }
352
+ retractSeat();
353
+ if (toolCalls.length > 0 && turnController.signal.aborted && !signal?.aborted) {
354
+ return await settleInterruptedTurn(state, message, executor, toolResults, signal, emit);
355
+ }
306
356
  await emit({ type: "turn_end", message, toolResults });
307
357
  const nextTurnContext = {
308
358
  message,
@@ -311,15 +361,7 @@ async function runSingleTurn(state, signal, emit, streamFn, runtime, trace) {
311
361
  newMessages: state.newMessages,
312
362
  };
313
363
  const nextTurnSnapshot = await state.config.prepareNextTurn?.(nextTurnContext);
314
- if (nextTurnSnapshot) {
315
- state.context = nextTurnSnapshot.context ?? state.context;
316
- state.config = Object.assign({}, state.config, {
317
- model: nextTurnSnapshot.model ?? state.config.model,
318
- reasoning: nextTurnSnapshot.thinkingLevel === undefined
319
- ? state.config.reasoning
320
- : nextTurnSnapshot.thinkingLevel,
321
- });
322
- }
364
+ adoptNextTurnSnapshot(state, nextTurnSnapshot);
323
365
  if (await state.config.shouldStopAfterTurn?.({
324
366
  message,
325
367
  toolResults,
@@ -383,6 +425,102 @@ async function runSingleTurn(state, signal, emit, streamFn, runtime, trace) {
383
425
  }
384
426
  return { kind: "ran" };
385
427
  }
428
+ function adoptNextTurnSnapshot(state, nextTurnSnapshot) {
429
+ if (!nextTurnSnapshot)
430
+ return;
431
+ state.context = nextTurnSnapshot.context ?? state.context;
432
+ state.config = Object.assign({}, state.config, {
433
+ model: nextTurnSnapshot.model ?? state.config.model,
434
+ reasoning: nextTurnSnapshot.thinkingLevel === undefined
435
+ ? state.config.reasoning
436
+ : nextTurnSnapshot.thinkingLevel,
437
+ });
438
+ }
439
+ const TURN_INTERRUPTED_TOOL_TEXT = "[INTERRUPTED] The user interrupted this turn before this tool call started. It was never executed and " +
440
+ "had no side effects. Address the user's interjection first; re-issue the call afterwards if it is still needed.";
441
+ async function settleInterruptedTurn(state, message, executor, committedResults, signal, emit) {
442
+ const toolResults = [...committedResults];
443
+ toolResults.push(...(await harvestExecutorOnErrorFinal(executor, state, message, signal, emit)));
444
+ const turnCalls = message.content.filter((c) => c.type === "toolCall");
445
+ const answered = new Set(toolResults.map((r) => r.toolCallId));
446
+ for (const call of turnCalls) {
447
+ if (answered.has(call.id))
448
+ continue;
449
+ await emit({
450
+ type: "tool_execution_start",
451
+ toolCallId: call.id,
452
+ toolName: call.name,
453
+ args: call.arguments,
454
+ });
455
+ const finalized = {
456
+ toolCall: call,
457
+ result: {
458
+ content: [{ type: "text", text: TURN_INTERRUPTED_TOOL_TEXT }],
459
+ details: { errorKind: "interrupted_never_started" },
460
+ },
461
+ isError: true,
462
+ };
463
+ await emit({
464
+ type: "tool_execution_end",
465
+ toolCallId: call.id,
466
+ toolName: call.name,
467
+ result: finalized.result,
468
+ isError: true,
469
+ notExecuted: true,
470
+ });
471
+ const toolResultMessage = createToolResultMessage(finalized);
472
+ await emitToolResultMessage(toolResultMessage, emit);
473
+ state.context.messages.push(toolResultMessage);
474
+ state.newMessages.push(toolResultMessage);
475
+ toolResults.push(toolResultMessage);
476
+ }
477
+ if (turnCalls.length === 0 &&
478
+ isBlankFailureContent(message) &&
479
+ state.context.messages[state.context.messages.length - 1] === message) {
480
+ state.context.messages.pop();
481
+ if (state.newMessages[state.newMessages.length - 1] === message)
482
+ state.newMessages.pop();
483
+ }
484
+ const marker = {
485
+ role: "user",
486
+ content: [
487
+ {
488
+ type: "text",
489
+ text: turnCalls.length > 0 ? INTERRUPTED_BY_USER_FOR_TOOL_USE_MARKER : INTERRUPTED_BY_USER_MARKER,
490
+ },
491
+ ],
492
+ provenance: "engine-note",
493
+ timestamp: Date.now(),
494
+ };
495
+ await emit({ type: "message_start", message: marker });
496
+ await emit({ type: "message_end", message: marker });
497
+ state.context.messages.push(marker);
498
+ state.newMessages.push(marker);
499
+ state.hasMoreToolCalls = false;
500
+ await emit({ type: "turn_end", message, toolResults });
501
+ if (signal?.aborted) {
502
+ await emit({ type: "agent_end", messages: state.newMessages });
503
+ return { kind: "terminal", reason: "aborted_before_stream" };
504
+ }
505
+ const nextTurnSnapshot = await state.config.prepareNextTurn?.({
506
+ message,
507
+ toolResults,
508
+ context: state.context,
509
+ newMessages: state.newMessages,
510
+ });
511
+ adoptNextTurnSnapshot(state, nextTurnSnapshot);
512
+ if (await state.config.shouldStopAfterTurn?.({
513
+ message,
514
+ toolResults,
515
+ context: state.context,
516
+ newMessages: state.newMessages,
517
+ })) {
518
+ await emit({ type: "agent_end", messages: state.newMessages });
519
+ return { kind: "terminal", reason: "stop_requested" };
520
+ }
521
+ state.pendingMessages = (await state.config.getSteeringMessages?.()) || [];
522
+ return { kind: "ran" };
523
+ }
386
524
  async function streamAssistantResponse(context, config, signal, emit, streamFn, runtime, executor, staticReasoningCutDowngrade) {
387
525
  let messages = context.messages;
388
526
  if (config.transformContext) {