@sema-agent/core 5.55.0 → 5.57.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 (81) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/dist/agents/send-message-tool.d.ts +11 -0
  3. package/dist/agents/send-message-tool.js +81 -13
  4. package/dist/agents/subagent.js +250 -89
  5. package/dist/agents/team.d.ts +10 -1
  6. package/dist/agents/team.js +1 -0
  7. package/dist/brain/anthropic.js +15 -5
  8. package/dist/brain/circuit-breaker.js +2 -1
  9. package/dist/brain/degrading.js +4 -1
  10. package/dist/brain/failover.js +16 -1
  11. package/dist/brain/open-responses.js +15 -5
  12. package/dist/brain/openai.js +16 -5
  13. package/dist/brain/request-params.d.ts +30 -27
  14. package/dist/brain/request-params.js +1 -7
  15. package/dist/brain/route-adjudicator.d.ts +190 -0
  16. package/dist/brain/route-adjudicator.js +189 -0
  17. package/dist/brain/route-conformance.d.ts +55 -0
  18. package/dist/brain/route-conformance.js +136 -0
  19. package/dist/brain/routing.js +8 -3
  20. package/dist/core/auto-compaction.d.ts +17 -4
  21. package/dist/core/auto-compaction.js +3 -0
  22. package/dist/core/context-edit.d.ts +55 -6
  23. package/dist/core/context-edit.js +12 -1
  24. package/dist/core/hooks.d.ts +293 -11
  25. package/dist/core/hooks.js +158 -11
  26. package/dist/core/human-input-projection.d.ts +20 -2
  27. package/dist/core/human-input-projection.js +9 -0
  28. package/dist/core/mcp.js +4 -4
  29. package/dist/core/memory-engine/engine.d.ts +15 -5
  30. package/dist/core/memory-engine/engine.js +3 -1
  31. package/dist/core/permission-rule-consent.d.ts +45 -0
  32. package/dist/core/permission-rule-consent.js +40 -11
  33. package/dist/core/permission-rule-model.d.ts +110 -75
  34. package/dist/core/permission-rule-model.js +61 -28
  35. package/dist/core/permission-rules.d.ts +23 -15
  36. package/dist/core/permission-rules.js +40 -31
  37. package/dist/core/runner/prepare-task.d.ts +8 -0
  38. package/dist/core/runner/prepare-task.js +66 -26
  39. package/dist/core/runner/runtask.d.ts +4 -1
  40. package/dist/core/runner/runtask.js +206 -21
  41. package/dist/core/runner/session-rule-policy.js +5 -5
  42. package/dist/core/scheduler.d.ts +5 -0
  43. package/dist/core/session-reconcile.d.ts +32 -0
  44. package/dist/core/session-reconcile.js +15 -0
  45. package/dist/core/side-query.d.ts +12 -5
  46. package/dist/core/task-notification.d.ts +34 -7
  47. package/dist/core/task-notification.js +11 -1
  48. package/dist/core/task-registry-agent.d.ts +20 -3
  49. package/dist/core/task-registry-agent.js +31 -2
  50. package/dist/core/tool-policy.d.ts +14 -9
  51. package/dist/core/tool-policy.js +27 -22
  52. package/dist/core/types.d.ts +69 -11
  53. package/dist/core/untrusted-text.js +8 -0
  54. package/dist/engine/compaction/compaction.d.ts +77 -7
  55. package/dist/engine/compaction/compaction.js +98 -9
  56. package/dist/engine/compaction/utils.d.ts +4 -0
  57. package/dist/engine/compaction/utils.js +6 -0
  58. package/dist/engine/harness/agent-harness.d.ts +84 -0
  59. package/dist/engine/harness/agent-harness.js +114 -13
  60. package/dist/engine/harness/messages.d.ts +4 -2
  61. package/dist/engine/harness/messages.js +7 -2
  62. package/dist/engine/harness/types.d.ts +16 -6
  63. package/dist/engine/llm/types.d.ts +65 -0
  64. package/dist/engine/loop/types.d.ts +7 -0
  65. package/dist/engine/session/import-validate.js +10 -0
  66. package/dist/engine/session/session.js +2 -2
  67. package/dist/index.d.ts +4 -1
  68. package/dist/index.js +3 -1
  69. package/dist/internal/llm.d.ts +1 -1
  70. package/dist/orchestration/run-spec.js +8 -1
  71. package/dist/prompts/default.d.ts +12 -6
  72. package/dist/prompts/default.js +2 -0
  73. package/dist/scenarios/scenario-registry.d.ts +5 -1
  74. package/dist/scenarios/scenario-registry.js +4 -2
  75. package/dist/tools/fs/index.js +8 -1
  76. package/dist/tools/scheduler-tools.js +28 -6
  77. package/dist/tools/web.d.ts +15 -0
  78. package/dist/tools/web.js +8 -2
  79. package/dist/tools/worktree.js +2 -2
  80. package/package.json +1 -1
  81. package/test/export-surface.snapshot.json +19 -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;
@@ -2,6 +2,7 @@ import { snapshotActorAssertion, stripEngineMetadata } from "../llm/index.js";
2
2
  import { runAgentLoop } from "../loop/agent-loop.js";
3
3
  import { resolveAgentCoreStreamFn } from "../loop/runtime-deps.js";
4
4
  import { normalizeEngineSegments } from "../../core/untrusted-text.js";
5
+ import { AUTH_CARRIER_NAMES } from "../../brain/request-params.js";
5
6
  import { convertToLlm } from "./messages.js";
6
7
  import { AgentHarnessError, CompactionError, SessionError, toError, } from "./types.js";
7
8
  function createUserMessage(text, images, provenance) {
@@ -35,6 +36,8 @@ function createUserMessage(text, images, provenance) {
35
36
  }
36
37
  const engineNotePayloads = new WeakMap();
37
38
  const engineAuthoredInjections = new WeakSet();
39
+ const userInputParkRecords = new WeakMap();
40
+ const drainedFromLane = new WeakMap();
38
41
  function isEngineAuthoredInjection(options) {
39
42
  if (options === undefined)
40
43
  return false;
@@ -82,6 +85,28 @@ function mergeHeaders(...headers) {
82
85
  }
83
86
  return hasHeaders ? merged : undefined;
84
87
  }
88
+ function dropAuthCarriers(bag) {
89
+ if (!bag)
90
+ return bag;
91
+ const out = {};
92
+ for (const [k, v] of Object.entries(bag)) {
93
+ if (!AUTH_CARRIER_NAMES.has(k.toLowerCase()))
94
+ out[k] = v;
95
+ }
96
+ return out;
97
+ }
98
+ function refuseAuthCarriersInStreamOptions(bag) {
99
+ if (!bag)
100
+ return;
101
+ for (const k of Object.keys(bag)) {
102
+ if (AUTH_CARRIER_NAMES.has(k.toLowerCase())) {
103
+ throw new Error(`streamOptions.headers must not carry an auth header ("${k}") — the run-static bag is not an auth channel ` +
104
+ `(the route pairing law reads options-borne carriers as per-model credentials, which a run-wide bag is not). ` +
105
+ `Put a deployment credential on the brain config (apiKey / headers, where its URL half is judged), or serve ` +
106
+ `per-model credentials through getApiKeyAndHeaders.`);
107
+ }
108
+ }
109
+ }
85
110
  function applyStreamOptionsPatch(base, patch) {
86
111
  const result = cloneStreamOptions(base);
87
112
  if (!patch) {
@@ -160,6 +185,8 @@ export class AgentHarness {
160
185
  session;
161
186
  phase = "idle";
162
187
  _stopAfterTurn = false;
188
+ aborting = false;
189
+ drainedPendingInjection = [];
163
190
  runAbortController;
164
191
  runPromise;
165
192
  pendingSessionWrites = [];
@@ -170,7 +197,7 @@ export class AgentHarness {
170
197
  onEngineNoteConsumed;
171
198
  engineInjectionsHeld;
172
199
  recoverUndrainedEngineNotes() {
173
- this.sweepUndrainedEngineNotes([this.nextTurnQueue, this.steerQueue, this.followUpQueue]);
200
+ this.sweepUndrainedEngineNotes([this.drainedPendingInjection, this.nextTurnQueue, this.steerQueue, this.followUpQueue]);
174
201
  }
175
202
  sweepUndrainedEngineNotes(queues) {
176
203
  const undrained = [];
@@ -193,9 +220,10 @@ export class AgentHarness {
193
220
  }
194
221
  }
195
222
  announceUndrainedUserInputs() {
223
+ const inFlight = (lane) => this.drainedPendingInjection.filter((m) => !engineAuthoredInjections.has(m) && drainedFromLane.get(m) === lane).length;
196
224
  const counts = {
197
- steer: this.steerQueue.filter((m) => !engineAuthoredInjections.has(m)).length,
198
- followUp: this.followUpQueue.filter((m) => !engineAuthoredInjections.has(m)).length,
225
+ steer: this.steerQueue.filter((m) => !engineAuthoredInjections.has(m)).length + inFlight("steer"),
226
+ followUp: this.followUpQueue.filter((m) => !engineAuthoredInjections.has(m)).length + inFlight("followUp"),
199
227
  };
200
228
  if ((counts.steer > 0 || counts.followUp > 0) && this.onUndrainedUserInputs) {
201
229
  try {
@@ -234,6 +262,7 @@ export class AgentHarness {
234
262
  this.env = options.env;
235
263
  this.session = options.session;
236
264
  this.resources = options.resources ?? {};
265
+ refuseAuthCarriersInStreamOptions(options.streamOptions?.headers);
237
266
  this.streamOptions = cloneStreamOptions(options.streamOptions);
238
267
  this.systemPrompt = options.systemPrompt;
239
268
  this.systemBlocks = options.systemBlocks;
@@ -421,7 +450,7 @@ export class AgentHarness {
421
450
  const auth = await this.getApiKeyAndHeaders?.(model);
422
451
  const snapshotOptions = {
423
452
  ...turnState.streamOptions,
424
- headers: mergeHeaders(turnState.streamOptions.headers, auth?.headers),
453
+ headers: mergeHeaders(dropAuthCarriers(turnState.streamOptions.headers), auth?.headers),
425
454
  };
426
455
  const requestOptions = await this.emitBeforeProviderRequest(model, turnState.sessionId, snapshotOptions);
427
456
  return resolveAgentCoreStreamFn(this.runtime)(model, context, {
@@ -452,6 +481,8 @@ export class AgentHarness {
452
481
  }
453
482
  async drainQueuedMessages(queue, mode) {
454
483
  let messages;
484
+ if (this.aborting)
485
+ return [];
455
486
  if (this.engineInjectionsHeld?.() === true) {
456
487
  messages = [];
457
488
  const limit = mode === "all" ? Number.POSITIVE_INFINITY : 1;
@@ -474,6 +505,12 @@ export class AgentHarness {
474
505
  if (messages.length === 0) {
475
506
  return messages;
476
507
  }
508
+ const lane = queue === this.steerQueue ? "steer" : queue === this.followUpQueue ? "followUp" : undefined;
509
+ if (lane !== undefined) {
510
+ for (const m of messages)
511
+ drainedFromLane.set(m, lane);
512
+ this.drainedPendingInjection.push(...messages);
513
+ }
477
514
  try {
478
515
  await this.emitQueueUpdate();
479
516
  for (const m of messages) {
@@ -485,15 +522,28 @@ export class AgentHarness {
485
522
  catch {
486
523
  }
487
524
  }
488
- engineNotePayloads.delete(m);
489
525
  }
490
526
  return messages;
491
527
  }
492
528
  catch (error) {
493
529
  queue.unshift(...messages);
530
+ this.unbookInFlight(messages);
494
531
  throw normalizeHookError(error);
495
532
  }
496
533
  }
534
+ unbookInFlight(messages) {
535
+ if (messages.length === 0 || this.drainedPendingInjection.length === 0)
536
+ return;
537
+ const gone = new Set(messages);
538
+ this.drainedPendingInjection = this.drainedPendingInjection.filter((m) => !gone.has(m));
539
+ }
540
+ settleInjectedFrame(message) {
541
+ if (!this.drainedPendingInjection.includes(message))
542
+ return;
543
+ this.unbookInFlight([message]);
544
+ userInputParkRecords.delete(message);
545
+ engineNotePayloads.delete(message);
546
+ }
497
547
  createLoopConfig(getTurnState, setTurnState) {
498
548
  const turnState = getTurnState();
499
549
  const self = this;
@@ -633,6 +683,7 @@ export class AgentHarness {
633
683
  let entryId;
634
684
  if (!isEmptyFailureAssistant(event.message)) {
635
685
  entryId = await this.session.appendMessage(event.message);
686
+ this.settleInjectedFrame(event.message);
636
687
  }
637
688
  await this.emitAny(entryId !== undefined ? { ...event, entryId } : event, signal);
638
689
  return;
@@ -656,7 +707,7 @@ export class AgentHarness {
656
707
  }
657
708
  if (event.type === "agent_end") {
658
709
  await this.flushPendingSessionWrites();
659
- this.sweepUndrainedEngineNotes([this.nextTurnQueue, this.steerQueue, this.followUpQueue]);
710
+ this.sweepUndrainedEngineNotes([this.drainedPendingInjection, this.nextTurnQueue, this.steerQueue, this.followUpQueue]);
660
711
  const undrainedUser = this.announceUndrainedUserInputs();
661
712
  this.phase = "idle";
662
713
  await this.emitAny(event, signal);
@@ -757,6 +808,19 @@ export class AgentHarness {
757
808
  throw new AgentHarnessError("busy", "AgentHarness is busy");
758
809
  }
759
810
  this._stopAfterTurn = false;
811
+ this.aborting = false;
812
+ if (this.drainedPendingInjection.length > 0) {
813
+ const stranded = this.drainedPendingInjection;
814
+ this.drainedPendingInjection = [];
815
+ for (let i = stranded.length - 1; i >= 0; i--) {
816
+ const frame = stranded[i];
817
+ const lane = drainedFromLane.get(frame);
818
+ if (lane === "steer")
819
+ this.steerQueue.unshift(frame);
820
+ else if (lane === "followUp")
821
+ this.followUpQueue.unshift(frame);
822
+ }
823
+ }
760
824
  this.phase = "turn";
761
825
  const finishRunPromise = this.startRunPromise();
762
826
  try {
@@ -786,6 +850,8 @@ export class AgentHarness {
786
850
  engineNotePayloads.set(m, options.enginePayload);
787
851
  if (isEngineAuthoredInjection(options))
788
852
  engineAuthoredInjections.add(m);
853
+ if (options?.parkRecord !== undefined)
854
+ userInputParkRecords.set(m, options.parkRecord);
789
855
  queue.push(m);
790
856
  await this.emitQueueUpdate();
791
857
  }
@@ -909,6 +975,7 @@ export class AgentHarness {
909
975
  return cloneStreamOptions(this.streamOptions);
910
976
  }
911
977
  async setStreamOptions(streamOptions) {
978
+ refuseAuthCarriersInStreamOptions(streamOptions?.headers);
912
979
  this.streamOptions = cloneStreamOptions(streamOptions);
913
980
  }
914
981
  setSystemPrompt(prompt, blocks) {
@@ -938,12 +1005,41 @@ export class AgentHarness {
938
1005
  throw normalizeHarnessError(error, "session");
939
1006
  }
940
1007
  }
1008
+ readParkableUserInputs() {
1009
+ const out = [];
1010
+ for (const q of [this.drainedPendingInjection, this.steerQueue, this.followUpQueue]) {
1011
+ for (const m of q) {
1012
+ const record = userInputParkRecords.get(m);
1013
+ if (record !== undefined)
1014
+ out.push(record);
1015
+ }
1016
+ }
1017
+ return out;
1018
+ }
1019
+ dropParkedUserInputs(records) {
1020
+ const dropped = { steer: 0, followUp: 0 };
1021
+ if (records.length === 0)
1022
+ return dropped;
1023
+ const migrated = new Set(records);
1024
+ for (const q of [this.drainedPendingInjection, this.steerQueue, this.followUpQueue]) {
1025
+ const fixedLane = q === this.steerQueue ? "steer" : q === this.followUpQueue ? "followUp" : undefined;
1026
+ for (let i = 0; i < q.length; i++) {
1027
+ const frame = q[i];
1028
+ const record = userInputParkRecords.get(frame);
1029
+ if (record === undefined || !migrated.has(record))
1030
+ continue;
1031
+ const lane = fixedLane ?? drainedFromLane.get(frame);
1032
+ userInputParkRecords.delete(frame);
1033
+ q.splice(i, 1);
1034
+ i--;
1035
+ if (lane !== undefined)
1036
+ dropped[lane] += 1;
1037
+ }
1038
+ }
1039
+ return dropped;
1040
+ }
941
1041
  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 = [];
1042
+ this.aborting = true;
947
1043
  this.runAbortController?.abort();
948
1044
  const errors = [];
949
1045
  try {
@@ -958,8 +1054,13 @@ export class AgentHarness {
958
1054
  catch (error) {
959
1055
  errors.push(toError(error));
960
1056
  }
1057
+ this.sweepUndrainedEngineNotes([this.drainedPendingInjection, this.nextTurnQueue, this.steerQueue, this.followUpQueue]);
1058
+ const userAuthored = (m) => !engineAuthoredInjections.has(m);
1059
+ const inFlightOf = (lane) => this.drainedPendingInjection.filter((m) => userAuthored(m) && drainedFromLane.get(m) === lane);
1060
+ const undrainedSteer = [...inFlightOf("steer"), ...this.steerQueue.filter(userAuthored)];
1061
+ const undrainedFollowUp = [...inFlightOf("followUp"), ...this.followUpQueue.filter(userAuthored)];
961
1062
  try {
962
- await this.emitOwn({ type: "abort", clearedSteer, clearedFollowUp });
1063
+ await this.emitOwn({ type: "abort", undrainedSteer, undrainedFollowUp });
963
1064
  }
964
1065
  catch (error) {
965
1066
  errors.push(toError(error));
@@ -968,7 +1069,7 @@ export class AgentHarness {
968
1069
  const cause = errors.length === 1 ? errors[0] : new AggregateError(errors, "Abort completed with errors");
969
1070
  throw normalizeHarnessError(cause, "hook");
970
1071
  }
971
- return { clearedSteer, clearedFollowUp };
1072
+ return { undrainedSteer, undrainedFollowUp };
972
1073
  }
973
1074
  async waitForIdle() {
974
1075
  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",