@sema-agent/core 5.55.0 → 5.56.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 (42) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/agents/send-message-tool.js +48 -2
  3. package/dist/agents/subagent.js +250 -89
  4. package/dist/core/auto-compaction.d.ts +17 -4
  5. package/dist/core/auto-compaction.js +3 -0
  6. package/dist/core/context-edit.d.ts +55 -6
  7. package/dist/core/context-edit.js +12 -1
  8. package/dist/core/hooks.d.ts +293 -11
  9. package/dist/core/hooks.js +158 -11
  10. package/dist/core/human-input-projection.d.ts +20 -2
  11. package/dist/core/human-input-projection.js +9 -0
  12. package/dist/core/permission-rules.d.ts +23 -15
  13. package/dist/core/permission-rules.js +40 -31
  14. package/dist/core/runner/prepare-task.d.ts +8 -0
  15. package/dist/core/runner/prepare-task.js +34 -23
  16. package/dist/core/runner/runtask.js +158 -21
  17. package/dist/core/runner/session-rule-policy.js +5 -5
  18. package/dist/core/session-reconcile.d.ts +32 -0
  19. package/dist/core/session-reconcile.js +15 -0
  20. package/dist/core/task-notification.d.ts +34 -7
  21. package/dist/core/task-notification.js +11 -1
  22. package/dist/core/task-registry-agent.d.ts +20 -3
  23. package/dist/core/task-registry-agent.js +31 -2
  24. package/dist/core/tool-policy.d.ts +14 -9
  25. package/dist/core/tool-policy.js +27 -22
  26. package/dist/core/types.d.ts +37 -11
  27. package/dist/core/untrusted-text.js +8 -0
  28. package/dist/engine/compaction/compaction.d.ts +77 -7
  29. package/dist/engine/compaction/compaction.js +98 -9
  30. package/dist/engine/compaction/utils.d.ts +4 -0
  31. package/dist/engine/compaction/utils.js +6 -0
  32. package/dist/engine/harness/agent-harness.d.ts +84 -0
  33. package/dist/engine/harness/agent-harness.js +88 -12
  34. package/dist/engine/harness/messages.d.ts +4 -2
  35. package/dist/engine/harness/messages.js +7 -2
  36. package/dist/engine/harness/types.d.ts +11 -5
  37. package/dist/engine/loop/types.d.ts +7 -0
  38. package/dist/engine/session/import-validate.js +10 -0
  39. package/dist/engine/session/session.js +2 -2
  40. package/dist/orchestration/run-spec.js +8 -1
  41. package/dist/prompts/default.d.ts +10 -4
  42. package/package.json +1 -1
@@ -2,7 +2,7 @@ import { resolveAgentCoreCompleteFn, } from "../loop/runtime-deps.js";
2
2
  import { asAgentMessage, convertToLlm, createCompactionSummaryMessage, createCustomMessage, } from "../harness/messages.js";
3
3
  import { buildSessionContext } from "../session/session.js";
4
4
  import { CompactionError, err, ok, } from "../harness/types.js";
5
- import { budgetInvokedSkillsRetention, computeFileLists, createFileOps, extractFileOpsFromMessage, extractInvokedSkills, extractPersistedOutputRefs, formatFileOperations, formatPersistedOutputRefs, PERSISTED_OUTPUT_REFS_MAX_ENTRIES, readElidedMessages, readPersistedOutputRefs, readRetainedInvokedSkills, replaceInvokedSkillBodiesForSummary, serializeConversation, stripFileOperationsFooter, } from "./utils.js";
5
+ import { budgetInvokedSkillsRetention, computeFileLists, createFileOps, extractFileOpsFromMessage, extractInvokedSkills, extractPersistedOutputRefs, formatFileOperations, formatPersistedOutputRefs, PERSISTED_OUTPUT_REFS_MAX_ENTRIES, readElidedMessages, readPersistedOutputRefs, readRetainedInvokedSkills, readUnsummarizedMessages, replaceInvokedSkillBodiesForSummary, serializeConversation, stripFileOperationsFooter, } from "./utils.js";
6
6
  function safeJsonStringify(value) {
7
7
  try {
8
8
  return JSON.stringify(value) ?? "undefined";
@@ -434,6 +434,58 @@ Then, after </analysis>, write the summary. Your summary should include the foll
434
434
  9. Optional Next Step: List the next step that you will take that is related to the most recent work you were doing. IMPORTANT: ensure that this step is DIRECTLY in line with the user's most recent explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the users request. Do not start on tangential requests or really old requests that were already completed without confirming with the user first.
435
435
  If there is a next step, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no drift in task interpretation.
436
436
 
437
+ Here's an example of how your output should be structured:
438
+
439
+ <example>
440
+ <analysis>
441
+ [Your thought process, ensuring all points are covered thoroughly and accurately]
442
+ </analysis>
443
+
444
+ <summary>
445
+ 1. Primary Request and Intent:
446
+ [Detailed description]
447
+
448
+ 2. Key Technical Concepts:
449
+ - [Concept 1]
450
+ - [Concept 2]
451
+ - [...]
452
+
453
+ 3. Files and Code Sections:
454
+ - [File Name 1]
455
+ - [Summary of why this file is important]
456
+ - [Summary of the changes made to this file, if any]
457
+ - [Important Code Snippet]
458
+ - [File Name 2]
459
+ - [Important Code Snippet]
460
+ - [...]
461
+
462
+ 4. Errors and fixes:
463
+ - [Detailed description of error 1]:
464
+ - [How you fixed the error]
465
+ - [User feedback on the error if any]
466
+ - [...]
467
+
468
+ 5. Problem Solving:
469
+ [Description of solved problems and ongoing troubleshooting]
470
+
471
+ 6. All user messages:
472
+ - [Detailed non tool use user message]
473
+ - [...]
474
+
475
+ 7. Pending Tasks:
476
+ - [Task 1]
477
+ - [Task 2]
478
+ - [...]
479
+
480
+ 8. Current Work:
481
+ [Precise description of current work]
482
+
483
+ 9. Optional Next Step:
484
+ [Optional Next step to take]
485
+
486
+ </summary>
487
+ </example>
488
+
437
489
  Please provide your summary based on the conversation so far, following this structure and ensuring precision and thoroughness in your response.
438
490
 
439
491
  Keep each section concise. Preserve exact file paths, function names, and error messages.`;
@@ -617,13 +669,28 @@ function splitMessageGroups(messages) {
617
669
  }
618
670
  return groups;
619
671
  }
672
+ const SUMMARY_SCRATCH_PREFIX_RE = /^\s*<analysis>[\s\S]*?<\/analysis>\s*/i;
673
+ const SUMMARY_ENVELOPE_RE = /^<summary>([\s\S]*)<\/summary>$/i;
674
+ function stripSummaryEnvelope(text) {
675
+ const scratch = SUMMARY_SCRATCH_PREFIX_RE.exec(text);
676
+ const head = scratch === null ? "" : scratch[0];
677
+ const m = SUMMARY_ENVELOPE_RE.exec(text.slice(head.length).trim());
678
+ if (m === null)
679
+ return text;
680
+ return head + (m[1] ?? "").trim();
681
+ }
620
682
  async function summarizeWithPtlRetry(req) {
621
- const disclose = (droppedChars, keptChars) => {
683
+ const disclose = (droppedChars, keptChars, droppedMessages) => {
622
684
  if (droppedChars <= 0) {
623
685
  return;
624
686
  }
625
687
  try {
626
- req.onInputTruncated?.({ label: req.truncationLabel, droppedChars, keptChars });
688
+ req.onInputTruncated?.({
689
+ label: req.truncationLabel,
690
+ droppedChars,
691
+ keptChars,
692
+ ...(droppedMessages !== undefined && droppedMessages > 0 ? { droppedMessages } : {}),
693
+ });
627
694
  }
628
695
  catch {
629
696
  }
@@ -659,7 +726,11 @@ async function summarizeWithPtlRetry(req) {
659
726
  thrown = e;
660
727
  }
661
728
  if (!didThrow && result && result.ok) {
662
- return result;
729
+ const unwrapped = stripSummaryEnvelope(result.value);
730
+ if (unwrapped.trim() === "") {
731
+ return err(markEmptySummaryClass(new CompactionError("summarization_failed", `${req.label} produced an empty summary (the response was an empty <summary> envelope)`)));
732
+ }
733
+ return ok(unwrapped);
663
734
  }
664
735
  const failureMessage = didThrow
665
736
  ? thrown instanceof Error
@@ -688,12 +759,14 @@ async function summarizeWithPtlRetry(req) {
688
759
  const beforeChars = serialized.length;
689
760
  const rollbackTargetChars = Math.max(SUMMARY_INPUT_MIN_KEEP_CHARS, beforeChars - Math.max(gapChars, 1));
690
761
  let remainingChars = beforeChars;
762
+ let droppedMessages = 0;
691
763
  while (groups.length > 1 && remainingChars > rollbackTargetChars) {
692
764
  const dropped = groups.shift();
765
+ droppedMessages += dropped.length;
693
766
  remainingChars -= serializeConversation(dropped).length + 2;
694
767
  }
695
768
  droppedForRetry = true;
696
- disclose(beforeChars - Math.max(remainingChars, 0), Math.max(remainingChars, 0));
769
+ disclose(beforeChars - Math.max(remainingChars, 0), Math.max(remainingChars, 0), droppedMessages);
697
770
  }
698
771
  }
699
772
  function markEmptySummaryClass(e) {
@@ -896,6 +969,9 @@ export function prepareCompaction(pathEntries, settings, charsPerToken = DEFAULT
896
969
  ? readElidedMessages(pathEntries[prevCompactionIndex].details)
897
970
  : undefined;
898
971
  const elidedMessages = (prevElided ?? 0) + messagesToSummarize.length + turnPrefixMessages.length;
972
+ const carriedUnsummarizedMessages = prevCompactionIndex >= 0 && !pathEntries[prevCompactionIndex].fromHook
973
+ ? readUnsummarizedMessages(pathEntries[prevCompactionIndex].details)
974
+ : undefined;
899
975
  const [summarizeReady, prefixReady] = replaceInvokedSkillBodiesForSummary([messagesToSummarize, turnPrefixMessages], new Set(invokedSkills.map((s) => s.name)));
900
976
  return ok({
901
977
  firstKeptEntryId,
@@ -908,6 +984,9 @@ export function prepareCompaction(pathEntries, settings, charsPerToken = DEFAULT
908
984
  invokedSkills,
909
985
  persistedOutputRefs,
910
986
  elidedMessages,
987
+ ...(carriedUnsummarizedMessages !== undefined && carriedUnsummarizedMessages > 0
988
+ ? { carriedUnsummarizedMessages }
989
+ : {}),
911
990
  settings,
912
991
  });
913
992
  }
@@ -927,7 +1006,14 @@ Summarize the prefix to provide context for the retained suffix:
927
1006
  Be concise. Focus on what's needed to understand the kept suffix.`;
928
1007
  export { computeFileLists, serializeConversation } from "./utils.js";
929
1008
  export async function compact(preparation, model, apiKey, headers, customInstructions, signal, thinkingLevel, streamFn, runtime, charsPerToken, onInputTruncated, onPtlRetry, forkContext) {
930
- const { firstKeptEntryId, messagesToSummarize, turnPrefixMessages, isSplitTurn, tokensBefore, previousSummary, fileOps, invokedSkills, persistedOutputRefs, elidedMessages, settings, } = preparation;
1009
+ const { firstKeptEntryId, messagesToSummarize, turnPrefixMessages, isSplitTurn, tokensBefore, previousSummary, fileOps, invokedSkills, persistedOutputRefs, elidedMessages, carriedUnsummarizedMessages, settings, } = preparation;
1010
+ let uncoveredThisPass = 0;
1011
+ const observeInputTruncated = (info) => {
1012
+ if (info.droppedMessages !== undefined && info.droppedMessages > 0) {
1013
+ uncoveredThisPass += info.droppedMessages;
1014
+ }
1015
+ onInputTruncated?.(info);
1016
+ };
931
1017
  if (!firstKeptEntryId) {
932
1018
  return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration"));
933
1019
  }
@@ -957,10 +1043,10 @@ export async function compact(preparation, model, apiKey, headers, customInstruc
957
1043
  else if (isSplitTurn && turnPrefixMessages.length > 0) {
958
1044
  const [historyResult, turnPrefixResult] = await Promise.all([
959
1045
  messagesToSummarize.length > 0
960
- ? generateSummary(messagesToSummarize, model, summaryBudget, apiKey, headers, signal, customInstructions, previousSummary, thinkingLevel, streamFn, runtime, charsPerToken, onInputTruncated, onPtlRetry)
1046
+ ? generateSummary(messagesToSummarize, model, summaryBudget, apiKey, headers, signal, customInstructions, previousSummary, thinkingLevel, streamFn, runtime, charsPerToken, observeInputTruncated, onPtlRetry)
961
1047
  :
962
1048
  Promise.resolve(ok(previousSummary ? stripFileOperationsFooter(previousSummary) : "No prior history.")),
963
- generateTurnPrefixSummary(turnPrefixMessages, model, summaryBudget, apiKey, headers, signal, customInstructions, thinkingLevel, streamFn, runtime, charsPerToken, onInputTruncated, onPtlRetry),
1049
+ generateTurnPrefixSummary(turnPrefixMessages, model, summaryBudget, apiKey, headers, signal, customInstructions, thinkingLevel, streamFn, runtime, charsPerToken, observeInputTruncated, onPtlRetry),
964
1050
  ]);
965
1051
  if (!historyResult.ok) {
966
1052
  return err(historyResult.error);
@@ -971,7 +1057,7 @@ export async function compact(preparation, model, apiKey, headers, customInstruc
971
1057
  summary = `${historyResult.value}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value}`;
972
1058
  }
973
1059
  else {
974
- const summaryResult = await generateSummary(messagesToSummarize, model, summaryBudget, apiKey, headers, signal, customInstructions, previousSummary, thinkingLevel, streamFn, runtime, charsPerToken, onInputTruncated, onPtlRetry);
1060
+ const summaryResult = await generateSummary(messagesToSummarize, model, summaryBudget, apiKey, headers, signal, customInstructions, previousSummary, thinkingLevel, streamFn, runtime, charsPerToken, observeInputTruncated, onPtlRetry);
975
1061
  if (!summaryResult.ok) {
976
1062
  return err(summaryResult.error);
977
1063
  }
@@ -991,6 +1077,9 @@ export async function compact(preparation, model, apiKey, headers, customInstruc
991
1077
  ...(invokedSkills !== undefined && invokedSkills.length > 0 ? { invokedSkills } : {}),
992
1078
  ...(persistedOutputRefs !== undefined && persistedOutputRefs.length > 0 ? { persistedOutputRefs } : {}),
993
1079
  ...(elidedMessages !== undefined && elidedMessages > 0 ? { elidedMessages } : {}),
1080
+ ...((carriedUnsummarizedMessages ?? 0) + uncoveredThisPass > 0
1081
+ ? { unsummarizedMessages: (carriedUnsummarizedMessages ?? 0) + uncoveredThisPass }
1082
+ : {}),
994
1083
  },
995
1084
  });
996
1085
  }
@@ -94,6 +94,10 @@ export declare function extractPersistedOutputRefs(messages: AgentMessage[], int
94
94
  * safe integer, else absent (pre-field sessions / foreign shapes yield undefined ⇒ no wrapper
95
95
  * disclosure — the byte-identical legacy render). */
96
96
  export declare function readElidedMessages(details: unknown): number | undefined;
97
+ /** Defensive reader for persisted `CompactionDetails.unsummarizedMessages` (PTL-shortfall accounting): same contract as
98
+ * {@link readElidedMessages} — a non-negative safe integer, else absent (pre-field sessions /
99
+ * foreign shapes ⇒ no uncovered-scale clause, byte-identical wrapper). */
100
+ export declare function readUnsummarizedMessages(details: unknown): number | undefined;
97
101
  /** Defensive reader for persisted `CompactionDetails.activeTools` (RB-402, blackboard [2132]③c/
98
102
  * [2133]): the ToolSearch activation snapshot each landed compaction restates. details is
99
103
  * `unknown` on the wire — pre-field sessions and foreign shapes yield an empty list; non-string/
@@ -205,6 +205,12 @@ export function readElidedMessages(details) {
205
205
  const raw = details.elidedMessages;
206
206
  return typeof raw === "number" && Number.isSafeInteger(raw) && raw >= 0 ? raw : undefined;
207
207
  }
208
+ export function readUnsummarizedMessages(details) {
209
+ if (details === null || typeof details !== "object")
210
+ return undefined;
211
+ const raw = details.unsummarizedMessages;
212
+ return typeof raw === "number" && Number.isSafeInteger(raw) && raw >= 0 ? raw : undefined;
213
+ }
208
214
  export function readCompactionActiveTools(details) {
209
215
  if (details === null || typeof details !== "object")
210
216
  return [];
@@ -38,6 +38,15 @@ export interface UserMessageProvenance {
38
38
  * projection point `projectHumanInput`) BEFORE the frame wrapping — the harness never rewrites
39
39
  * text, so annotation stays adjacent to the frame it attributes. */
40
40
  actor?: ActorAssertion;
41
+ /** backlog #389 — an OPAQUE record the caller rides on a CALLER-AUTHORED queued frame so a durable
42
+ * park can carry the instruction across the suspend instead of losing it. Same sidecar posture as
43
+ * {@link UserMessageProvenance.enginePayload} (a WeakMap entry, never a message field, never
44
+ * serialized, never interpreted here) but a different lane and a different fate: an engine payload
45
+ * is handed back for per-session redelivery, a park record is handed to the checkpoint mint through
46
+ * {@link AgentHarness.readParkableUserInputs}. The harness holds the FRAMED text; the record is what
47
+ * the parked-steer queue needs (pre-framing text + trust + inputId), which only the minting seam
48
+ * knows — hence opaque. */
49
+ parkRecord?: unknown;
41
50
  }
42
51
  /**
43
52
  * Harness-level recovery wiring (design/118 ④b/⑤). The loop's prompt-too-long seam wants a
@@ -80,6 +89,23 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
80
89
  * boundary so the loop exits CLEANLY (emit `agent_end` + return) WITHOUT an abort/interrupt — the path a
81
90
  * resource-slice suspend needs (an abort would trip the orphan-[INTERRUPTED] reconcile = corrupt resume). */
82
91
  private _stopAfterTurn;
92
+ /** backlog #389 — one-way latch set by {@link abort}. Read by `drainQueuedMessages`: no boundary may
93
+ * hand a queued frame to the model once the run is unwinding, and — crucially — none may SPLICE one
94
+ * out of the queue either (that would delete the terminal undrained account the abort path owes it). */
95
+ private aborting;
96
+ /** backlog #389 (codex adversarial r1, HIGH-1) — frames a boundary has REMOVED from a queue but that
97
+ * have not yet entered the model's context.
98
+ *
99
+ * The abort latch alone leaves a real window: `drainQueuedMessages` splices first and then awaits
100
+ * `emitQueueUpdate`, and even after it returns the loop has one more decision point before it injects
101
+ * anything. An `abort()` landing in either gap finds both queues empty, so the terminal account —
102
+ * which reads the queues — reports zero for a frame that will never reach the model. That is the very
103
+ * silent loss this ticket exists to close, one layer deeper.
104
+ *
105
+ * A frame sits here from the splice until its `message_end` (the moment it is in the transcript);
106
+ * the terminal account, the abort snapshot and the durable-park migration all read it alongside the
107
+ * queues, so "accepted" is covered end to end rather than only while it happens to be in an array. */
108
+ private drainedPendingInjection;
83
109
  private runAbortController?;
84
110
  private runPromise?;
85
111
  private pendingSessionWrites;
@@ -189,6 +215,11 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
189
215
  private createContext;
190
216
  private createStreamFn;
191
217
  private drainQueuedMessages;
218
+ /** #389 — drop these frames from the in-flight account (they went back on a queue, or they landed). */
219
+ private unbookInFlight;
220
+ /** #389 — a queued frame has reached the transcript: it is delivered, so it leaves the in-flight
221
+ * account and gives up its park record (a suspend must never re-park what the model has read). */
222
+ private settleInjectedFrame;
192
223
  private createLoopConfig;
193
224
  /**
194
225
  * design/118 ④b/⑤: wire (or clear) the loop's recovery chains. The runner calls this after
@@ -279,6 +310,59 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
279
310
  * the queue is empty.
280
311
  */
281
312
  flushQueuedSessionWrites(): Promise<void>;
313
+ /**
314
+ * backlog #389 — the accepted-but-undelivered CALLER frames a durable suspend can carry forward,
315
+ * in delivery order (steer before followUp, each queue forward — the order the live loop serves).
316
+ * NON-DESTRUCTIVE by design: the mint that reads this may still fail to commit, and a frame dropped
317
+ * for a checkpoint that never landed would be lost twice over. {@link dropParkedUserInputs} is the
318
+ * second half, called only once the checkpoint is durably committed.
319
+ *
320
+ * Only frames carrying a park record are returned — engine-authored notes have their own lossless
321
+ * lane (`onUndrainedEngineNotes` → pend per session) and must not be parked twice.
322
+ */
323
+ readParkableUserInputs(): unknown[];
324
+ /**
325
+ * backlog #389 — the COMMIT half of {@link readParkableUserInputs}: the named records are now durable
326
+ * on a checkpoint, so their live frames leave the queues (identity-matched on the sidecar). Dropping
327
+ * them here is what keeps the terminal `#259` account honest — a migrated instruction is not "accepted
328
+ * then lost", and one this is never called for (a failed commit) still gets its loud undrained notice.
329
+ * Returns the per-lane drop counts so the caller can subtract them from an account already taken.
330
+ */
331
+ dropParkedUserInputs(records: readonly unknown[]): {
332
+ steer: number;
333
+ followUp: number;
334
+ };
335
+ /**
336
+ * backlog #389 (CC 2.1.223 `control_request:"interrupt"`, `CC:1064478-1064513`) — an interrupt does
337
+ * NOT destroy accepted user input. CC's default keeps the queue and answers `still_queued`; only an
338
+ * explicit `cancel_queued` cancels, with a per-uuid `cancelled` receipt. This harness had the opposite
339
+ * polarity: both queues were emptied unconditionally and the contents handed back on a result object
340
+ * with ZERO readers in the repo — so the #259 "accepted then never consumed must be LOUD" account (run
341
+ * at `agent_end`) counted the emptied queues and found nothing to announce. An input accepted one
342
+ * millisecond before `interrupt()` therefore vanished behind its own `queued` receipt.
343
+ *
344
+ * The queues are now LEFT STANDING. The abort latch above stops any surviving boundary from draining
345
+ * (or silently splicing) them, and the terminal `agent_end` account — the one disclosure leg, unchanged
346
+ * — reports them through `onUndrainedUserInputs` + the `settled` frame, exactly as it does for a run
347
+ * that ended naturally with input still queued. Engine notes keep their own lossless recovery sweep,
348
+ * which must still run HERE: it is the one path `agent_end` cannot cover on its own (this sweep also
349
+ * serves a `waitForIdle` that never reaches `agent_end`).
350
+ *
351
+ * A sema `interrupt()` ends the RUN (CC's ends a turn inside a living session), so there is no state in
352
+ * which "still queued" means "will run next" — hence no `cancel_queued` opt-in: its two settings would
353
+ * be indistinguishable at the package face, which is the dead-knob shape this same ticket family flags
354
+ * elsewhere. The CC-shaped "survives the pause" seat is the DURABLE PARK, served by
355
+ * {@link readParkableUserInputs}.
356
+ *
357
+ * RECORDED, because the two halves must not be read as contradicting each other (adversarial r2
358
+ * MEDIUM-6): the #259 notice says a stranded input is "NOT redelivered", and that is a statement about
359
+ * the RUNNER's contract — a `TaskStream.steer` is never served to a later run, because the Runner
360
+ * builds one harness per run and prompts it exactly once. A host driving `AgentHarness` DIRECTLY (it is
361
+ * not on the package export surface) and re-prompting the same instance WILL see the leftovers served,
362
+ * both the queued ones (true before this ticket too) and the in-flight ones the next `prompt()`
363
+ * restores. Clearing them here instead is not available: the durable-park migration reads them AFTER
364
+ * this method returns, so an abort that empties the queues would take the park's payload with it.
365
+ */
282
366
  abort(): Promise<AbortResult>;
283
367
  waitForIdle(): Promise<void>;
284
368
  subscribe(listener: (event: AgentHarnessEvent<TSkill, TPromptTemplate>, signal?: AbortSignal) => Promise<void> | void): () => void;
@@ -35,6 +35,8 @@ function createUserMessage(text, images, provenance) {
35
35
  }
36
36
  const engineNotePayloads = new WeakMap();
37
37
  const engineAuthoredInjections = new WeakSet();
38
+ const userInputParkRecords = new WeakMap();
39
+ const drainedFromLane = new WeakMap();
38
40
  function isEngineAuthoredInjection(options) {
39
41
  if (options === undefined)
40
42
  return false;
@@ -160,6 +162,8 @@ export class AgentHarness {
160
162
  session;
161
163
  phase = "idle";
162
164
  _stopAfterTurn = false;
165
+ aborting = false;
166
+ drainedPendingInjection = [];
163
167
  runAbortController;
164
168
  runPromise;
165
169
  pendingSessionWrites = [];
@@ -170,7 +174,7 @@ export class AgentHarness {
170
174
  onEngineNoteConsumed;
171
175
  engineInjectionsHeld;
172
176
  recoverUndrainedEngineNotes() {
173
- this.sweepUndrainedEngineNotes([this.nextTurnQueue, this.steerQueue, this.followUpQueue]);
177
+ this.sweepUndrainedEngineNotes([this.drainedPendingInjection, this.nextTurnQueue, this.steerQueue, this.followUpQueue]);
174
178
  }
175
179
  sweepUndrainedEngineNotes(queues) {
176
180
  const undrained = [];
@@ -193,9 +197,10 @@ export class AgentHarness {
193
197
  }
194
198
  }
195
199
  announceUndrainedUserInputs() {
200
+ const inFlight = (lane) => this.drainedPendingInjection.filter((m) => !engineAuthoredInjections.has(m) && drainedFromLane.get(m) === lane).length;
196
201
  const counts = {
197
- steer: this.steerQueue.filter((m) => !engineAuthoredInjections.has(m)).length,
198
- followUp: this.followUpQueue.filter((m) => !engineAuthoredInjections.has(m)).length,
202
+ steer: this.steerQueue.filter((m) => !engineAuthoredInjections.has(m)).length + inFlight("steer"),
203
+ followUp: this.followUpQueue.filter((m) => !engineAuthoredInjections.has(m)).length + inFlight("followUp"),
199
204
  };
200
205
  if ((counts.steer > 0 || counts.followUp > 0) && this.onUndrainedUserInputs) {
201
206
  try {
@@ -452,6 +457,8 @@ export class AgentHarness {
452
457
  }
453
458
  async drainQueuedMessages(queue, mode) {
454
459
  let messages;
460
+ if (this.aborting)
461
+ return [];
455
462
  if (this.engineInjectionsHeld?.() === true) {
456
463
  messages = [];
457
464
  const limit = mode === "all" ? Number.POSITIVE_INFINITY : 1;
@@ -474,6 +481,12 @@ export class AgentHarness {
474
481
  if (messages.length === 0) {
475
482
  return messages;
476
483
  }
484
+ const lane = queue === this.steerQueue ? "steer" : queue === this.followUpQueue ? "followUp" : undefined;
485
+ if (lane !== undefined) {
486
+ for (const m of messages)
487
+ drainedFromLane.set(m, lane);
488
+ this.drainedPendingInjection.push(...messages);
489
+ }
477
490
  try {
478
491
  await this.emitQueueUpdate();
479
492
  for (const m of messages) {
@@ -485,15 +498,28 @@ export class AgentHarness {
485
498
  catch {
486
499
  }
487
500
  }
488
- engineNotePayloads.delete(m);
489
501
  }
490
502
  return messages;
491
503
  }
492
504
  catch (error) {
493
505
  queue.unshift(...messages);
506
+ this.unbookInFlight(messages);
494
507
  throw normalizeHookError(error);
495
508
  }
496
509
  }
510
+ unbookInFlight(messages) {
511
+ if (messages.length === 0 || this.drainedPendingInjection.length === 0)
512
+ return;
513
+ const gone = new Set(messages);
514
+ this.drainedPendingInjection = this.drainedPendingInjection.filter((m) => !gone.has(m));
515
+ }
516
+ settleInjectedFrame(message) {
517
+ if (!this.drainedPendingInjection.includes(message))
518
+ return;
519
+ this.unbookInFlight([message]);
520
+ userInputParkRecords.delete(message);
521
+ engineNotePayloads.delete(message);
522
+ }
497
523
  createLoopConfig(getTurnState, setTurnState) {
498
524
  const turnState = getTurnState();
499
525
  const self = this;
@@ -633,6 +659,7 @@ export class AgentHarness {
633
659
  let entryId;
634
660
  if (!isEmptyFailureAssistant(event.message)) {
635
661
  entryId = await this.session.appendMessage(event.message);
662
+ this.settleInjectedFrame(event.message);
636
663
  }
637
664
  await this.emitAny(entryId !== undefined ? { ...event, entryId } : event, signal);
638
665
  return;
@@ -656,7 +683,7 @@ export class AgentHarness {
656
683
  }
657
684
  if (event.type === "agent_end") {
658
685
  await this.flushPendingSessionWrites();
659
- this.sweepUndrainedEngineNotes([this.nextTurnQueue, this.steerQueue, this.followUpQueue]);
686
+ this.sweepUndrainedEngineNotes([this.drainedPendingInjection, this.nextTurnQueue, this.steerQueue, this.followUpQueue]);
660
687
  const undrainedUser = this.announceUndrainedUserInputs();
661
688
  this.phase = "idle";
662
689
  await this.emitAny(event, signal);
@@ -757,6 +784,19 @@ export class AgentHarness {
757
784
  throw new AgentHarnessError("busy", "AgentHarness is busy");
758
785
  }
759
786
  this._stopAfterTurn = false;
787
+ this.aborting = false;
788
+ if (this.drainedPendingInjection.length > 0) {
789
+ const stranded = this.drainedPendingInjection;
790
+ this.drainedPendingInjection = [];
791
+ for (let i = stranded.length - 1; i >= 0; i--) {
792
+ const frame = stranded[i];
793
+ const lane = drainedFromLane.get(frame);
794
+ if (lane === "steer")
795
+ this.steerQueue.unshift(frame);
796
+ else if (lane === "followUp")
797
+ this.followUpQueue.unshift(frame);
798
+ }
799
+ }
760
800
  this.phase = "turn";
761
801
  const finishRunPromise = this.startRunPromise();
762
802
  try {
@@ -786,6 +826,8 @@ export class AgentHarness {
786
826
  engineNotePayloads.set(m, options.enginePayload);
787
827
  if (isEngineAuthoredInjection(options))
788
828
  engineAuthoredInjections.add(m);
829
+ if (options?.parkRecord !== undefined)
830
+ userInputParkRecords.set(m, options.parkRecord);
789
831
  queue.push(m);
790
832
  await this.emitQueueUpdate();
791
833
  }
@@ -938,12 +980,41 @@ export class AgentHarness {
938
980
  throw normalizeHarnessError(error, "session");
939
981
  }
940
982
  }
983
+ readParkableUserInputs() {
984
+ const out = [];
985
+ for (const q of [this.drainedPendingInjection, this.steerQueue, this.followUpQueue]) {
986
+ for (const m of q) {
987
+ const record = userInputParkRecords.get(m);
988
+ if (record !== undefined)
989
+ out.push(record);
990
+ }
991
+ }
992
+ return out;
993
+ }
994
+ dropParkedUserInputs(records) {
995
+ const dropped = { steer: 0, followUp: 0 };
996
+ if (records.length === 0)
997
+ return dropped;
998
+ const migrated = new Set(records);
999
+ for (const q of [this.drainedPendingInjection, this.steerQueue, this.followUpQueue]) {
1000
+ const fixedLane = q === this.steerQueue ? "steer" : q === this.followUpQueue ? "followUp" : undefined;
1001
+ for (let i = 0; i < q.length; i++) {
1002
+ const frame = q[i];
1003
+ const record = userInputParkRecords.get(frame);
1004
+ if (record === undefined || !migrated.has(record))
1005
+ continue;
1006
+ const lane = fixedLane ?? drainedFromLane.get(frame);
1007
+ userInputParkRecords.delete(frame);
1008
+ q.splice(i, 1);
1009
+ i--;
1010
+ if (lane !== undefined)
1011
+ dropped[lane] += 1;
1012
+ }
1013
+ }
1014
+ return dropped;
1015
+ }
941
1016
  async abort() {
942
- this.sweepUndrainedEngineNotes([this.nextTurnQueue, this.steerQueue, this.followUpQueue]);
943
- const clearedSteer = [...this.steerQueue];
944
- const clearedFollowUp = [...this.followUpQueue];
945
- this.steerQueue = [];
946
- this.followUpQueue = [];
1017
+ this.aborting = true;
947
1018
  this.runAbortController?.abort();
948
1019
  const errors = [];
949
1020
  try {
@@ -958,8 +1029,13 @@ export class AgentHarness {
958
1029
  catch (error) {
959
1030
  errors.push(toError(error));
960
1031
  }
1032
+ this.sweepUndrainedEngineNotes([this.drainedPendingInjection, this.nextTurnQueue, this.steerQueue, this.followUpQueue]);
1033
+ const userAuthored = (m) => !engineAuthoredInjections.has(m);
1034
+ const inFlightOf = (lane) => this.drainedPendingInjection.filter((m) => userAuthored(m) && drainedFromLane.get(m) === lane);
1035
+ const undrainedSteer = [...inFlightOf("steer"), ...this.steerQueue.filter(userAuthored)];
1036
+ const undrainedFollowUp = [...inFlightOf("followUp"), ...this.followUpQueue.filter(userAuthored)];
961
1037
  try {
962
- await this.emitOwn({ type: "abort", clearedSteer, clearedFollowUp });
1038
+ await this.emitOwn({ type: "abort", undrainedSteer, undrainedFollowUp });
963
1039
  }
964
1040
  catch (error) {
965
1041
  errors.push(toError(error));
@@ -968,7 +1044,7 @@ export class AgentHarness {
968
1044
  const cause = errors.length === 1 ? errors[0] : new AggregateError(errors, "Abort completed with errors");
969
1045
  throw normalizeHarnessError(cause, "hook");
970
1046
  }
971
- return { clearedSteer, clearedFollowUp };
1047
+ return { undrainedSteer, undrainedFollowUp };
972
1048
  }
973
1049
  async waitForIdle() {
974
1050
  await this.runPromise;
@@ -10,8 +10,10 @@ export declare const COMPACTION_SUMMARY_SUFFIX = "\n</summary>\n\nRecent message
10
10
  export declare function bashExecutionToText(msg: BashExecutionMessage): string;
11
11
  /** Build a persisted compaction summary message from the repository timestamp string.
12
12
  * `elidedMessages` (RB-398-b①): the render-time count of messages this summary stands in for —
13
- * drives the wrapper's scale disclosure; omitted ⇒ the wrapper stays byte-identical. */
14
- export declare function createCompactionSummaryMessage(summary: string, tokensBefore: number, timestamp: string, elidedMessages?: number): CompactionSummaryMessage;
13
+ * drives the wrapper's scale disclosure; omitted ⇒ the wrapper stays byte-identical.
14
+ * `unsummarizedMessages` (PTL-shortfall accounting): how many of those the summary does NOT cover
15
+ * (input shed by a reactive prompt-too-long retry); omitted / 0 ⇒ no uncovered clause. */
16
+ export declare function createCompactionSummaryMessage(summary: string, tokensBefore: number, timestamp: string, elidedMessages?: number, unsummarizedMessages?: number): CompactionSummaryMessage;
15
17
  /** Build a custom transcript message that can be shown and replayed into context. */
16
18
  export declare function createCustomMessage(customType: string, content: string | (TextContent | ImageContent)[], display: boolean, details: unknown, timestamp: string): CustomMessage;
17
19
  /** Prefix stamped on a salvaged block so readers (model, summaries, humans) see it is a normalization
@@ -36,13 +36,14 @@ export function bashExecutionToText(msg) {
36
36
  }
37
37
  return text;
38
38
  }
39
- export function createCompactionSummaryMessage(summary, tokensBefore, timestamp, elidedMessages) {
39
+ export function createCompactionSummaryMessage(summary, tokensBefore, timestamp, elidedMessages, unsummarizedMessages) {
40
40
  return {
41
41
  role: "compactionSummary",
42
42
  summary,
43
43
  tokensBefore,
44
44
  timestamp: requireSessionTimestampMs(timestamp, "compaction summary timestamp"),
45
45
  ...(elidedMessages !== undefined ? { elidedMessages } : {}),
46
+ ...(unsummarizedMessages !== undefined ? { unsummarizedMessages } : {}),
46
47
  };
47
48
  }
48
49
  export function createCustomMessage(customType, content, display, details, timestamp) {
@@ -131,10 +132,14 @@ export function convertToLlm(messages) {
131
132
  }
132
133
  case "compactionSummary": {
133
134
  const n = message.elidedMessages;
135
+ const uncovered = message.unsummarizedMessages;
136
+ const uncoveredClause = n !== undefined && n > 0 && uncovered !== undefined && uncovered > 0
137
+ ? ` ${Math.min(uncovered, n)} of them could not be included in the summarization input and are NOT covered by this summary.`
138
+ : "";
134
139
  const disclosure = n !== undefined && n > 0
135
140
  ? `\n\n(For scale: this summary stands in for ${n} earlier message${n === 1 ? "" : "s"}${message.tokensBefore > 0
136
141
  ? ` — approximately ${message.tokensBefore} tokens of context before compaction`
137
- : ""}.)`
142
+ : ""}.${uncoveredClause})`
138
143
  : "";
139
144
  return {
140
145
  role: "user",
@@ -889,8 +889,12 @@ export interface SavePointEvent {
889
889
  }
890
890
  export interface AbortEvent {
891
891
  type: "abort";
892
- clearedSteer: AgentMessage[];
893
- clearedFollowUp: AgentMessage[];
892
+ /** backlog #389 — the caller frames still QUEUED when the abort fired. They are no longer cleared by
893
+ * `abort()` (CC keeps its queue on interrupt): the terminal `agent_end` account announces them, so
894
+ * this list is the same set that reaches `onUndrainedUserInputs`, observed one step earlier. */
895
+ undrainedSteer: AgentMessage[];
896
+ /** backlog #389 — the follow-up half of {@link AbortEvent.undrainedSteer}. */
897
+ undrainedFollowUp: AgentMessage[];
894
898
  }
895
899
  export interface SettledEvent {
896
900
  type: "settled";
@@ -1044,10 +1048,12 @@ export type AgentHarnessEventResultMap = {
1044
1048
  abort: undefined;
1045
1049
  settled: undefined;
1046
1050
  };
1047
- /** Queued messages removed by an abort operation. */
1051
+ /** backlog #389 what an abort found still QUEUED (it no longer removes caller frames; see
1052
+ * {@link AgentHarness.abort}). Engine-note frames are absent: their own sweep recovered them for
1053
+ * per-session redelivery before this snapshot was taken. */
1048
1054
  export interface AbortResult {
1049
- clearedSteer: AgentMessage[];
1050
- clearedFollowUp: AgentMessage[];
1055
+ undrainedSteer: AgentMessage[];
1056
+ undrainedFollowUp: AgentMessage[];
1051
1057
  }
1052
1058
  /** Compaction data supplied by hooks or returned from compaction preparation. */
1053
1059
  export interface CompactResult {
@@ -489,6 +489,13 @@ export interface CompactionSummaryMessage {
489
489
  * (`convertToLlm`); absent (directly constructed messages, older render paths) ⇒ the wrapper is
490
490
  * byte-identical to the historical constant. */
491
491
  elidedMessages?: number;
492
+ /** PTL-shortfall accounting: how many of those {@link elidedMessages} the summary does NOT cover — messages a reactive
493
+ * prompt-too-long retry shed from the summarization input after the cut was already committed.
494
+ * Sourced from the compaction entry's own write-side accounting
495
+ * (`CompactionDetails.unsummarizedMessages`), same reason as its sibling. Absent (the lossless
496
+ * common case, directly constructed messages, pre-field entries) ⇒ no uncovered clause, wrapper
497
+ * byte-identical. */
498
+ unsummarizedMessages?: number;
492
499
  /** Optional estimated context tokens after compaction. */
493
500
  tokensAfter?: number;
494
501
  /** Optional first retained entry id from the compaction range. */
@@ -183,6 +183,16 @@ export class StreamingImportValidator {
183
183
  if (elided !== undefined && !(typeof elided === "number" && Number.isSafeInteger(elided) && elided >= 0)) {
184
184
  throw new SessionError("invalid_session", `compaction "${e.id}" carries a structurally invalid elidedMessages count (non-negative integer)`);
185
185
  }
186
+ const uncovered = e.details?.unsummarizedMessages;
187
+ if (uncovered !== undefined) {
188
+ if (!(typeof uncovered === "number" && Number.isSafeInteger(uncovered) && uncovered >= 0)) {
189
+ throw new SessionError("invalid_session", `compaction "${e.id}" carries a structurally invalid unsummarizedMessages count (non-negative integer)`);
190
+ }
191
+ if (uncovered > 0 && !(typeof elided === "number" && elided >= uncovered)) {
192
+ throw new SessionError("invalid_session", `compaction "${e.id}" carries unsummarizedMessages ${uncovered} without an elidedMessages count at least that ` +
193
+ `large (uncovered messages are a subset of the folded ones)`);
194
+ }
195
+ }
186
196
  const refs = e.details?.persistedOutputRefs;
187
197
  if (refs !== undefined) {
188
198
  if (!Array.isArray(refs) || refs.length > PERSISTED_OUTPUT_REFS_MAX_ENTRIES) {