@sema-agent/core 5.50.0 → 5.52.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 +115 -0
  2. package/dist/agents/send-message-tool.d.ts +13 -2
  3. package/dist/agents/send-message-tool.js +13 -7
  4. package/dist/agents/subagent.js +16 -4
  5. package/dist/brain/anthropic.js +6 -2
  6. package/dist/brain/reasoning.d.ts +10 -2
  7. package/dist/brain/request-params.d.ts +20 -4
  8. package/dist/brain/status-sink.d.ts +56 -0
  9. package/dist/brain/status-sink.js +16 -0
  10. package/dist/core/auto-mode-prompt.js +9 -1
  11. package/dist/core/hooks.d.ts +24 -1
  12. package/dist/core/hooks.js +26 -4
  13. package/dist/core/mcp.js +37 -12
  14. package/dist/core/memory-engine/delegation-settlement.d.ts +15 -5
  15. package/dist/core/memory-engine/delegation-settlement.js +3 -3
  16. package/dist/core/memory-engine/engine.js +10 -2
  17. package/dist/core/reminder-disclosure.d.ts +41 -0
  18. package/dist/core/reminder-disclosure.js +11 -1
  19. package/dist/core/runner/assemble-result.d.ts +6 -0
  20. package/dist/core/runner/assemble-result.js +1 -1
  21. package/dist/core/runner/prepare-task.d.ts +15 -0
  22. package/dist/core/runner/prepare-task.js +89 -43
  23. package/dist/core/runner/runtask.d.ts +5 -1
  24. package/dist/core/runner/runtask.js +57 -26
  25. package/dist/core/task-registry-agent.js +3 -3
  26. package/dist/core/task-registry-shared.d.ts +6 -0
  27. package/dist/core/task-registry.js +4 -2
  28. package/dist/core/tool-policy.d.ts +54 -0
  29. package/dist/core/tool-policy.js +72 -12
  30. package/dist/core/tools.js +7 -0
  31. package/dist/core/trace.d.ts +13 -1
  32. package/dist/core/types.d.ts +51 -6
  33. package/dist/engine/harness/agent-harness.d.ts +30 -0
  34. package/dist/engine/harness/agent-harness.js +41 -7
  35. package/dist/engine/loop/agent-loop.js +95 -30
  36. package/dist/engine/loop/types.d.ts +32 -0
  37. package/dist/index.d.ts +1 -1
  38. package/dist/index.js +1 -1
  39. package/dist/tools/web.d.ts +10 -1
  40. package/dist/tools/web.js +5 -4
  41. package/package.json +1 -1
  42. package/test/export-surface.snapshot.json +4 -1
@@ -142,7 +142,7 @@ export type TraceEvent = {
142
142
  } | {
143
143
  /**
144
144
  * How a task's requested reasoning intensity RESOLVED against the model's real capability (design/96 S6).
145
- * Emitted once at task start when thinking is on, so a deployment can SEE — not silently swallow (§E
145
+ * Emitted at task start when thinking is on, so a deployment can SEE — not silently swallow (§E
146
146
  * honesty red-line) — that a binary provider ignored the tier (`graded:false`), that an effort endpoint
147
147
  * clamped it down (`clamped:true`), or that a NON-reasoning model dropped the request entirely
148
148
  * (`dropped:true` — the frame fires for that model too; it used to be the one arm with no report).
@@ -150,6 +150,18 @@ export type TraceEvent = {
150
150
  * task's PRIMARY serving model at leg entry (same law as `TaskResult.model`): a mid-run
151
151
  * degradation does not re-emit this frame — the switch is observed on its own seats
152
152
  * (`TaskResult.degraded`).
153
+ *
154
+ * A SECOND frame follows, for the same taskId, in exactly one case: the leg's FIRST
155
+ * committed request reported wire facts that CHANGE the resolution the task-start frame claimed
156
+ * (today: the anthropic budget path's cap-wins skip, where a hard per-request output cap too small
157
+ * to host a legal thinking budget deletes the thinking block — so the honest report is
158
+ * `dropped:true`, not the gradient the entry frame guessed). The correction is deduped (a first
159
+ * request that changes nothing emits nothing) and capped at one (only the FIRST committed request
160
+ * is consumed, so the leg's nested internal calls — compaction summary, side query — cannot
161
+ * rewrite the leg's posture). A consumer that keeps only the LATEST frame per taskId is therefore
162
+ * always reading the truth, and one that kept only the first now under-reports a drop it could not
163
+ * have seen before. The result-face twin `TaskResult.effectiveReasoning` moves with it — the two
164
+ * faces are one mint at every instant.
153
165
  */
154
166
  kind: "reasoning.resolved";
155
167
  version: 1;
@@ -270,7 +270,8 @@ export interface ToolSpec<TParams extends TSchema = TSchema> {
270
270
  * design/77 §4: optional deployment-injected probe for an `irreversibility:"maybe"` tool. Given the call's
271
271
  * (post-hook) args, it reports whether THIS specific call is reversible. The gate calls it ONLY when the
272
272
  * surviving decision is `allow` and `irreversibility` resolves to `"maybe"`; it is time-bounded (the
273
- * approval timeout) and **fail-closed**: a non-`reversible` verdict, a timeout, or a throw all tighten to
273
+ * approval timeout when configured, a 30s default when absent the probe wait is always finite) and
274
+ * **fail-closed**: a non-`reversible` verdict, a timeout, or a throw all tighten to
274
275
  * `ask`. Declaring this probe defaults `irreversibility` to `"maybe"`. It is read from the spec at
275
276
  * prepare-time and captured in a closure — NOT a tool argument — so the model cannot monkey-patch it.
276
277
  *
@@ -2898,15 +2899,25 @@ export interface TaskResult {
2898
2899
  * (`requested`/`effective`/`graded`/`clamped`/`format`/`endpoint`, plus `dropped:true` when a
2899
2900
  * non-reasoning model dropped the request entirely — field semantics on
2900
2901
  * {@link import("../brain/reasoning.js").ResolvedReasoning}). It is the SAME resolver output the
2901
- * `reasoning.resolved` trace frame carries, computed once per leg — the two faces cannot tell different
2902
- * stories; this seat serves consumers without a tracer (the trace frame is the deployment-observability
2903
- * face, this is the caller face).
2902
+ * `reasoning.resolved` trace frame carries — the two faces cannot tell different stories; this seat
2903
+ * serves consumers without a tracer (the trace frame is the deployment-observability face, this is the
2904
+ * caller face).
2904
2905
  *
2905
2906
  * **In-presence condition** — mirrors the trace frame exactly: present on every terminal of a leg that ran
2906
2907
  * with a REQUESTED thinking tier other than off/unset (the `spec > role > model.defaultThinking` chain);
2907
2908
  * absent when thinking was off/unset for the leg, and on prepare failures (the resolution is minted after
2908
2909
  * prepare). On a resumed task each leg re-resolves against the leg's own serving model.
2909
2910
  *
2911
+ * **Per-request correction** — the leg-entry mint runs before any request exists, so it can only
2912
+ * describe the model's CAPABILITY. When the leg's FIRST committed request reports wire facts that change
2913
+ * that answer, the seat is re-resolved against them by the same resolver and the trace twin is re-emitted
2914
+ * with it. Today that is one arm: the anthropic budget path's cap-wins skip — a HARD per-request output
2915
+ * cap too small to host a legal thinking budget deletes the thinking block, so a leg capped that way now
2916
+ * reports `effective:"off"`/`dropped:true` instead of claiming the gradient the wire never carried. Only
2917
+ * the FIRST committed request is consumed: the seat stays a leg-ENTRY snapshot (see the degradation law
2918
+ * below), and the leg's nested internal brain calls (compaction summary, tool-invoked side query) carry
2919
+ * their own caps and must never restate the leg's posture.
2920
+ *
2910
2921
  * **Degradation law** — same as {@link model}, whose resolution this is: the seat describes the leg's
2911
2922
  * PRIMARY serving model at leg entry. A mid-run degradation (reactive fallback / near-budget switch)
2912
2923
  * changes the serving model WITHOUT re-minting this seat or its trace twin — read {@link degraded} to see
@@ -3107,6 +3118,19 @@ export interface TaskResult {
3107
3118
  /** Human-readable statement of what did NOT happen and why — safe to show a user verbatim. */
3108
3119
  message: string;
3109
3120
  }>;
3121
+ /**
3122
+ * Present (`true`) exactly when the run's FINAL turn was halted by a person's BARE rejection of a
3123
+ * tool call — the parent-thread control-flow boundary: the rejected call's same-message siblings
3124
+ * that had not started were settled un-executed (each an error result coded `gate.batch_halted`),
3125
+ * and the engine deliberately did NOT re-invoke the model, so the run ends awaiting the user's
3126
+ * direction. On this path `status` is still `"completed"` (nothing failed, nothing is suspended,
3127
+ * the session is continuable as ever) — this field is what tells such an end apart from a natural
3128
+ * finish: `result` text is whatever the model had produced BEFORE the rejection (often empty), and
3129
+ * a consumer surface should read the state as "stopped by the user, awaiting their direction",
3130
+ * never as "the task finished its work". Absent everywhere else — including when queued user input
3131
+ * (a steer/follow-up) continued the run past the rejection and it later ended naturally.
3132
+ */
3133
+ haltedOnUserRejection?: true;
3110
3134
  /**
3111
3135
  * The deliveries of AskUserQuestion calls a person ANSWERED but whose call never executed to collect
3112
3136
  * the answer (the leg ended first — abort, batch teardown, or a loop failure). Rides EVERY terminal
@@ -3665,6 +3689,21 @@ export type TaskEvent = ({
3665
3689
  * must not treat "absent" as "a human decided".
3666
3690
  */
3667
3691
  settledBy?: import("./tool-policy.js").ApprovalSettledBy;
3692
+ /**
3693
+ * HOW the ask resolution refused, when this frame closes a call an in-process ask resolution
3694
+ * DENIED — the closed vocabulary of {@link import("./tool-policy.js").AskDenyResolution}
3695
+ * (`"human_refused"`, `"window_expired"`, `"no_approver"`, `"blanket_allow_refused"`,
3696
+ * `"approver_unavailable"`, `"task_aborted"`, `"presentation_failed"`, `"approver_error"`,
3697
+ * `"approver_contract"`). Finer-grained than {@link settledBy} (which only says what KIND of
3698
+ * end a settled wait had): this classifies the REFUSAL ARM itself, minted by the resolver at
3699
+ * the arm that composed the deny and carried verbatim on the engine-owned settlement sideband
3700
+ * — never derived from the result text or writable by a tool/policy (an out-of-vocabulary or
3701
+ * self-declared word is dropped at the screens, with a defect reported on the deployment's
3702
+ * error face). ABSENT on every frame that was not such a deny — every executed call, every
3703
+ * policy/hook direct deny, and the durable/decide lane's settlements (which carry their own
3704
+ * `settledBy`/reason instead). A consumer must not read a semantic out of the absence.
3705
+ */
3706
+ resolution?: import("./tool-policy.js").AskDenyResolution;
3668
3707
  /**
3669
3708
  * design/252 G-7 — WHOSE settlement that was: the identifier the approval channel reported for
3670
3709
  * the party that ended this wait, beside the {@link settledBy} word that says what KIND of end
@@ -4732,7 +4771,9 @@ export interface EngineNotice {
4732
4771
  * child build, not de-duplicated across builds: each spec is a distinct fact.
4733
4772
  * - `"mcp.revocation_probe_failed"` (design/338) — the deployment's `mcpRevocations.isRevoked`
4734
4773
  * probe threw; MCP dispatch FAILS OPEN (revocation is a tightening face) and this announces
4735
- * once per run. `detail: { message }`. The refusal itself (`mcp.server_revoked`) is a tool
4774
+ * once per materialization (a resume re-materializes and may announce again). `detail: { message }`
4775
+ * — no sessionId (a deployment wiring fact, not session-attributed), `"operator"` audience by
4776
+ * the {@link NOTICE_AUDIENCE} default. The refusal itself (`mcp.server_revoked`) is a tool
4736
4777
  * RESULT code, not a notice.
4737
4778
  * - `"config.models_swapped"` — `Runner.swapModels` replaced the model catalog generation
4738
4779
  * (zero-restart model switching). `detail: { models, tiers }` — key COUNTS only, never the
@@ -4910,7 +4951,11 @@ export interface RunnerDeps {
4910
4951
  * `mcp.server_revoked` with known-not-executed wording; in-flight calls a revocation raced are
4911
4952
  * deliberately not chased (the threat shape is "new calls after removal"). Absent seat = the
4912
4953
  * pre-338 semantics. A THROWING probe fails open (revocation is a tightening face — a broken
4913
- * probe must not brick every MCP call) with a once-per-run `mcp.revocation_probe_failed` notice.
4954
+ * probe must not brick every MCP call) with a once-per-MATERIALIZATION `mcp.revocation_probe_failed`
4955
+ * notice (a resume re-materializes and may announce again — the standing condition is re-news at
4956
+ * each fresh mount, never per-call). The notice is a deployment wiring fact: `detail: { message }`
4957
+ * only, no session attribution, `"operator"` audience by the {@link NOTICE_AUDIENCE} default — a
4958
+ * wire projector forwards it operator-tier and needs no per-session de-duplication of its own.
4914
4959
  */
4915
4960
  mcpRevocations?: {
4916
4961
  isRevoked(serverName: string): boolean;
@@ -20,6 +20,13 @@ export interface UserMessageProvenance {
20
20
  /** [c209-C] R3: engine-injected note whose content must stay VISIBLE in derived views (diagnostics,
21
21
  * task notifications, steering, recall) but never under user authority — see UserMessage.provenance. */
22
22
  provenance?: "engine-note";
23
+ /** The frame's CONTENT is caller/supervisor speech relayed by the engine (a TaskStream steer), not
24
+ * engine-authored guidance. Rides only WITH `provenance:"engine-note"` (the projection semantics are
25
+ * identical — visible, never `[user]` authority); what it changes is the run-control judgment: the
26
+ * human-halt hold below treats a caller-authored frame as USER-provenance input (always passes),
27
+ * while an engine-authored one is held. Minter-stated at the single relay seam that mints such
28
+ * frames — never inferred from text. Metadata only; not persisted on the message. */
29
+ callerAuthored?: true;
23
30
  /** RB-30 terminal fix — an OPAQUE payload the engine rides on this queued message (the runner's
24
31
  * task-notification frame). Never serialized, never interpreted by the harness: if the message is
25
32
  * still undrained at agent_end, the payload is handed back through `onUndrainedEngineNotes` so
@@ -98,6 +105,22 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
98
105
  * Advisory (swallow-guarded at the call sites via the runner's own closure); never re-entered
99
106
  * for the same payload — consumption deletes the sidecar entry in the same step. */
100
107
  onEngineNoteConsumed?: (payload: unknown) => void;
108
+ /** Runner-set HOLD — the single gate for every engine-authored injection against the run's live
109
+ * human-halt fact. When it reports true (a person's bare rejection halted the run at this
110
+ * boundary, and only that person's own input may continue it):
111
+ * · a NEW engine-authored steer/followUp is refused at entry (the caller's existing rejection
112
+ * path parks the payload for the session's next run — same lane an idle-race refusal takes);
113
+ * · engine-authored frames ALREADY queued are not drained — they stay queued, delayed past the
114
+ * halted boundary: delivered at the next un-halted boundary if the user's own queued input
115
+ * legitimately continues the run, otherwise handed to the terminal sweep (payload frames park
116
+ * losslessly; payload-less boundary advisories end with the run, the same best-effort fate
117
+ * their mint sites already accept for a final turn).
118
+ * Caller/user-provenance input always passes — it is the awaited direction. One predicate here
119
+ * instead of a per-lane check at every injection site: a future engine lane that never heard of
120
+ * the halt is held by construction rather than by remembering. `nextTurn` is deliberately NOT
121
+ * gated: its splice opens a NEW run (idle-park redelivery), and a halt belongs to the run that
122
+ * minted it. */
123
+ engineInjectionsHeld?: () => boolean;
101
124
  /** RB-30 codex F1/F2: shared recovery sweep — collects engine-note payloads from the given queues
102
125
  * in DELIVERY order (steer before followUp, each queue forward — the live loop serves steering
103
126
  * first, so the recovered redelivery must not present "later" frames ahead of "now/next"),
@@ -203,6 +226,13 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
203
226
  * The idle check is deliberately NOT here: its message differs per lane, and nextTurn() legitimately has
204
227
  * none (queueing the NEXT prompt while idle is exactly what it is for). */
205
228
  private enqueueInjection;
229
+ /** The human-halt hold's ENTRY half (see {@link engineInjectionsHeld}): a new engine-authored
230
+ * injection during a halt is refused before it can queue — the refusal is the caller's park
231
+ * signal (the same posture as the backlog-cap refusal above: pend per session, redeliver on the
232
+ * session's next run; nothing is lost). Belt-and-braces beside the drain-side hold: today's
233
+ * engine lanes each check the halt fact before injecting, so this arm exists for the lane that
234
+ * does not — including the one not written yet. */
235
+ private refuseHeldEngineInjection;
206
236
  steer(text: string, options?: {
207
237
  images?: ImageContent[];
208
238
  } & UserMessageProvenance): Promise<void>;
@@ -34,6 +34,14 @@ function createUserMessage(text, images, provenance) {
34
34
  };
35
35
  }
36
36
  const engineNotePayloads = new WeakMap();
37
+ const engineAuthoredInjections = new WeakSet();
38
+ function isEngineAuthoredInjection(options) {
39
+ if (options === undefined)
40
+ return false;
41
+ if (options.engineMinted === true || options.enginePayload !== undefined)
42
+ return true;
43
+ return options.provenance === "engine-note" && options.callerAuthored !== true;
44
+ }
37
45
  const ENGINE_NOTE_STEER_BACKLOG_CAP = 50;
38
46
  function createFailureMessage(model, error, aborted) {
39
47
  return {
@@ -160,6 +168,7 @@ export class AgentHarness {
160
168
  onUndrainedEngineNotes;
161
169
  onUndrainedUserInputs;
162
170
  onEngineNoteConsumed;
171
+ engineInjectionsHeld;
163
172
  recoverUndrainedEngineNotes() {
164
173
  this.sweepUndrainedEngineNotes([this.nextTurnQueue, this.steerQueue, this.followUpQueue]);
165
174
  }
@@ -184,7 +193,10 @@ export class AgentHarness {
184
193
  }
185
194
  }
186
195
  announceUndrainedUserInputs() {
187
- const counts = { steer: this.steerQueue.length, followUp: this.followUpQueue.length };
196
+ const counts = {
197
+ steer: this.steerQueue.filter((m) => !engineAuthoredInjections.has(m)).length,
198
+ followUp: this.followUpQueue.filter((m) => !engineAuthoredInjections.has(m)).length,
199
+ };
188
200
  if ((counts.steer > 0 || counts.followUp > 0) && this.onUndrainedUserInputs) {
189
201
  try {
190
202
  this.onUndrainedUserInputs(counts);
@@ -439,13 +451,26 @@ export class AgentHarness {
439
451
  };
440
452
  }
441
453
  async drainQueuedMessages(queue, mode) {
442
- let count = mode === "all" ? queue.length : 1;
443
- if (mode !== "all" && queue.length > 1 && engineNotePayloads.has(queue[0])) {
444
- count = 1;
445
- while (count < queue.length && engineNotePayloads.has(queue[count]))
446
- count++;
454
+ let messages;
455
+ if (this.engineInjectionsHeld?.() === true) {
456
+ messages = [];
457
+ const limit = mode === "all" ? Number.POSITIVE_INFINITY : 1;
458
+ for (let i = 0; i < queue.length && messages.length < limit; i++) {
459
+ if (engineAuthoredInjections.has(queue[i]))
460
+ continue;
461
+ messages.push(...queue.splice(i, 1));
462
+ i--;
463
+ }
464
+ }
465
+ else {
466
+ let count = mode === "all" ? queue.length : 1;
467
+ if (mode !== "all" && queue.length > 1 && engineNotePayloads.has(queue[0])) {
468
+ count = 1;
469
+ while (count < queue.length && engineNotePayloads.has(queue[count]))
470
+ count++;
471
+ }
472
+ messages = queue.splice(0, count);
447
473
  }
448
- const messages = queue.splice(0, count);
449
474
  if (messages.length === 0) {
450
475
  return messages;
451
476
  }
@@ -759,19 +784,28 @@ export class AgentHarness {
759
784
  const m = createUserMessage(text, options?.images, options);
760
785
  if (options?.enginePayload !== undefined)
761
786
  engineNotePayloads.set(m, options.enginePayload);
787
+ if (isEngineAuthoredInjection(options))
788
+ engineAuthoredInjections.add(m);
762
789
  queue.push(m);
763
790
  await this.emitQueueUpdate();
764
791
  }
792
+ refuseHeldEngineInjection(options) {
793
+ if (isEngineAuthoredInjection(options) && this.engineInjectionsHeld?.() === true) {
794
+ throw new AgentHarnessError("invalid_state", "engine-authored injection refused: the run is halted awaiting the user's own direction — park the payload for the session's next run");
795
+ }
796
+ }
765
797
  async steer(text, options) {
766
798
  if (this.phase === "idle") {
767
799
  throw new AgentHarnessError("invalid_state", "Cannot steer while idle");
768
800
  }
801
+ this.refuseHeldEngineInjection(options);
769
802
  await this.enqueueInjection(this.steerQueue, text, options);
770
803
  }
771
804
  async followUp(text, options) {
772
805
  if (this.phase === "idle") {
773
806
  throw new AgentHarnessError("invalid_state", "Cannot follow up while idle");
774
807
  }
808
+ this.refuseHeldEngineInjection(options);
775
809
  await this.enqueueInjection(this.followUpQueue, text, options);
776
810
  }
777
811
  async nextTurn(text, options) {
@@ -490,12 +490,14 @@ async function executeToolCallsSequential(currentContext, assistantMessage, tool
490
490
  });
491
491
  const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);
492
492
  let finalized;
493
+ let halt;
493
494
  if (preparation.kind === "immediate") {
494
495
  finalized = {
495
496
  toolCall,
496
497
  result: preparation.result,
497
498
  isError: preparation.isError,
498
499
  };
500
+ halt = preparation.halt;
499
501
  }
500
502
  else {
501
503
  finalized = await executePreparedWithDisclosure(currentContext, assistantMessage, preparation, config, signal, emit);
@@ -505,6 +507,16 @@ async function executeToolCallsSequential(currentContext, assistantMessage, tool
505
507
  await emitToolResultMessage(toolResultMessage, emit);
506
508
  finalizedCalls.push(finalized);
507
509
  messages.push(toolResultMessage);
510
+ if (halt !== undefined) {
511
+ for (const remaining of toolCalls.slice(i + 1)) {
512
+ if (signal?.aborted)
513
+ break;
514
+ const settled = await settleHaltedToolCall(remaining, halt, emit);
515
+ finalizedCalls.push(settled.finalized);
516
+ messages.push(settled.message);
517
+ }
518
+ return { messages, terminate: true };
519
+ }
508
520
  if (signal?.aborted) {
509
521
  break;
510
522
  }
@@ -514,6 +526,33 @@ async function executeToolCallsSequential(currentContext, assistantMessage, tool
514
526
  terminate: shouldTerminateToolBatch(finalizedCalls),
515
527
  };
516
528
  }
529
+ async function settleHaltedToolCall(toolCall, halt, emit) {
530
+ await emit({
531
+ type: "tool_execution_start",
532
+ toolCallId: toolCall.id,
533
+ toolName: toolCall.name,
534
+ args: toolCall.arguments,
535
+ });
536
+ const finalized = {
537
+ toolCall,
538
+ result: {
539
+ content: [{ type: "text", text: truncateError(halt.reason) }],
540
+ details: halt.details !== undefined ? { ...halt.details } : {},
541
+ },
542
+ isError: true,
543
+ };
544
+ await emit({
545
+ type: "tool_execution_end",
546
+ toolCallId: finalized.toolCall.id,
547
+ toolName: finalized.toolCall.name,
548
+ result: finalized.result,
549
+ isError: true,
550
+ notExecuted: true,
551
+ });
552
+ const message = createToolResultMessage(finalized);
553
+ await emitToolResultMessage(message, emit);
554
+ return { finalized, message };
555
+ }
517
556
  function isCallConcurrencySafe(tool, args) {
518
557
  if (!tool)
519
558
  return false;
@@ -587,6 +626,7 @@ async function executeToolCallsPartitioned(currentContext, assistantMessage, too
587
626
  const allFinalized = [];
588
627
  const messages = [];
589
628
  const remainingCalls = [];
629
+ let halt;
590
630
  for (const toolCall of toolCalls) {
591
631
  const held = executor?.take(toolCall.id);
592
632
  if (!held) {
@@ -599,6 +639,8 @@ async function executeToolCallsPartitioned(currentContext, assistantMessage, too
599
639
  await emitToolResultMessage(toolResultMessage, emit);
600
640
  allFinalized.push(finalized);
601
641
  messages.push(toolResultMessage);
642
+ if (held.immediate?.halt !== undefined)
643
+ halt ??= held.immediate.halt;
602
644
  }
603
645
  if (executor) {
604
646
  for (const orphan of executor.remaining()) {
@@ -606,33 +648,54 @@ async function executeToolCallsPartitioned(currentContext, assistantMessage, too
606
648
  await closeOrphanedStreamEntry(orphan, emit);
607
649
  }
608
650
  }
609
- const batches = partitionToolCalls(remainingCalls, currentContext.tools);
610
- outer: for (const batch of batches) {
611
- if (signal?.aborted)
612
- break;
613
- const entries = [];
614
- for (const toolCall of batch.calls) {
615
- await emit({
616
- type: "tool_execution_start",
617
- toolCallId: toolCall.id,
618
- toolName: toolCall.name,
619
- args: toolCall.arguments,
620
- });
621
- const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);
622
- if (preparation.kind === "immediate") {
623
- const finalized = { toolCall, result: preparation.result, isError: preparation.isError };
624
- await emitToolExecutionEnd(finalized, emit);
625
- entries.push({ finalized, toolCall });
626
- }
627
- else {
628
- entries.push({ prepared: preparation, toolCall });
629
- }
630
- if (signal?.aborted) {
631
- await settleBatch(entries, true);
632
- break outer;
651
+ if (halt === undefined) {
652
+ const batches = partitionToolCalls(remainingCalls, currentContext.tools);
653
+ outer: for (const batch of batches) {
654
+ if (signal?.aborted)
655
+ break;
656
+ const entries = [];
657
+ for (const toolCall of batch.calls) {
658
+ await emit({
659
+ type: "tool_execution_start",
660
+ toolCallId: toolCall.id,
661
+ toolName: toolCall.name,
662
+ args: toolCall.arguments,
663
+ });
664
+ const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);
665
+ if (preparation.kind === "immediate") {
666
+ const finalized = { toolCall, result: preparation.result, isError: preparation.isError };
667
+ await emitToolExecutionEnd(finalized, emit);
668
+ entries.push({ finalized, toolCall });
669
+ if (preparation.halt !== undefined) {
670
+ halt = preparation.halt;
671
+ break;
672
+ }
673
+ }
674
+ else {
675
+ entries.push({ prepared: preparation, toolCall });
676
+ }
677
+ if (signal?.aborted) {
678
+ await settleBatch(entries, true);
679
+ break outer;
680
+ }
633
681
  }
682
+ await settleBatch(entries, batch.safe);
683
+ if (halt !== undefined)
684
+ break;
634
685
  }
635
- await settleBatch(entries, batch.safe);
686
+ }
687
+ if (halt !== undefined && !signal?.aborted) {
688
+ const settledIds = new Set(messages.map((m) => m.toolCallId));
689
+ for (const toolCall of toolCalls) {
690
+ if (settledIds.has(toolCall.id))
691
+ continue;
692
+ if (signal?.aborted)
693
+ break;
694
+ const settled = await settleHaltedToolCall(toolCall, halt, emit);
695
+ allFinalized.push(settled.finalized);
696
+ messages.push(settled.message);
697
+ }
698
+ return { messages, terminate: true };
636
699
  }
637
700
  return { messages, terminate: shouldTerminateToolBatch(allFinalized) };
638
701
  async function settleBatch(entries, concurrent) {
@@ -720,6 +783,8 @@ class StreamToolExecutor {
720
783
  const preparation = await prepareToolCall(this.context, partialAssistant, entry.toolCall, this.config, this.signal);
721
784
  if (preparation.kind === "immediate") {
722
785
  entry.immediate = preparation;
786
+ if (preparation.halt !== undefined)
787
+ this.barrier = true;
723
788
  return;
724
789
  }
725
790
  entry.prepared = preparation;
@@ -873,11 +938,11 @@ async function prepareToolCall(currentContext, assistantMessage, toolCall, confi
873
938
  };
874
939
  }
875
940
  if (beforeResult?.block) {
876
- return {
877
- kind: "immediate",
878
- result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"),
879
- isError: true,
880
- };
941
+ const blocked = createErrorToolResult(beforeResult.reason || "Tool execution was blocked");
942
+ if (beforeResult.haltRemaining !== undefined) {
943
+ return { kind: "immediate", result: blocked, isError: true, halt: beforeResult.haltRemaining };
944
+ }
945
+ return { kind: "immediate", result: blocked, isError: true };
881
946
  }
882
947
  if (beforeResult?.updatedInput !== undefined) {
883
948
  finalArgs = validateToolArguments(tool, {
@@ -48,11 +48,34 @@ export type AgentToolCall = Extract<AssistantMessage["content"][number], {
48
48
  *
49
49
  * Keep in sync with the harness hook result `ToolCallResult` (harness/types.ts), which the harness's
50
50
  * `beforeToolCall` callback returns verbatim.
51
+ *
52
+ * `haltRemaining` (additive) makes the block a CONTROL-FLOW BOUNDARY for the whole assistant batch:
53
+ * honored only beside `block: true`, it tells the loop that every tool call of the same assistant
54
+ * message whose preparation had not yet begun must NOT begin — each settles as an error tool result
55
+ * built from the directive (so the transcript still answers every tool call), and the batch votes
56
+ * terminate (the loop starts no further assistant turn; queued follow-up/steering messages still
57
+ * run — they are the input the boundary is waiting for). Calls already executing when the block
58
+ * lands run to completion and settle with their real outcomes; preparation/adjudication is strictly
59
+ * source-ordered in every lane, so "not yet begun" is exactly "after the blocked call". The loop
60
+ * stays policy-agnostic: WHY a block halts the batch (e.g. a person's bare rejection) is entirely
61
+ * the gate owner's judgment, made once, at the site that returns this.
51
62
  */
52
63
  export interface BeforeToolCallResult {
53
64
  block?: boolean;
54
65
  reason?: string;
55
66
  updatedInput?: unknown;
67
+ haltRemaining?: HaltRemainingDirective;
68
+ }
69
+ /**
70
+ * The settlement {@link BeforeToolCallResult.haltRemaining} instructs the loop to apply to each
71
+ * same-batch tool call that never started: `reason` is the model-facing error text (bounded by the
72
+ * loop's standard error truncator), `details` is attached to each settled result verbatim (shallow-
73
+ * copied per call) so a machine-readable mark (e.g. `{ code }`) reaches every not-executed sibling's
74
+ * end frame and transcript entry.
75
+ */
76
+ export interface HaltRemainingDirective {
77
+ reason: string;
78
+ details?: Record<string, unknown>;
56
79
  }
57
80
  /**
58
81
  * Partial override returned from `afterToolCall`.
@@ -629,4 +652,13 @@ export type AgentEvent = {
629
652
  toolName: string;
630
653
  result: unknown;
631
654
  isError: boolean;
655
+ /**
656
+ * Present (`true`) exactly when this end frame closes a call the loop settled WITHOUT
657
+ * executing it — a never-started sibling under a batch-halt directive (see
658
+ * {@link BeforeToolCallResult.haltRemaining}). LOOP-AUTHORED: it rides the event, never the
659
+ * tool result, so a tool cannot claim it about itself; a consumer that scopes
660
+ * post-execution machinery to executed calls (batch observers, execution audits) keys on
661
+ * this the way it keys on its own blocked-call records. Absent on every executed call.
662
+ */
663
+ notExecuted?: true;
632
664
  };
package/dist/index.d.ts CHANGED
@@ -136,7 +136,7 @@ export type { SchedulerCapability, SchedulerErrorCode, ScheduledIntent, Schedule
136
136
  export { createSchedulerTools, type SchedulerToolContext, SCHEDULE_WAKEUP_TOOL_NAME, AUTONOMOUS_LOOP_SENTINEL, AUTONOMOUS_LOOP_DYNAMIC_SENTINEL, } from "./tools/scheduler-tools.js";
137
137
  export { resolveAutonomousLoopPrompt, AUTONOMOUS_LOOP_PREAMBLE, AUTONOMOUS_LOOP_PREAMBLE_PERSISTENT, type AutonomousLoopPromptOptions, } from "./tools/loop-tick.js";
138
138
  export { tightenTaskSpec, TaskSpecTightenError } from "./core/tighten-task-spec.js";
139
- export { createAllowDenyPolicy, createApprovalPolicy, COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, findUnverifiableRecursiveDelete, combinePolicies, decisionText, resolveAsk, toolPolicyNameSets, type ToolPolicyNameSets, type NamedToolPolicy, type ToolPolicyProjection, type ToolPolicyProjectionComponent, type ConstraintChainEntry, checkToolPolicyProjection, constraintChainEntryOf, constraintChainDigest, type ToolPolicy, type ToolCallRequest, type PermissionResult, type DecisionReason, type ApprovalSettledBy, APPROVAL_SETTLED_BY_VALUES, isApprovalSettledBy, screenApproverAttribution, APPROVER_ATTRIBUTION_MAX_CHARS, type OnAsk, type AskOutcome, type ResolvedAsk, type AskRequest, type AskDelegationProvenance, type AskRuleEvidence, type AskEvidenceAbsence, ASK_EVIDENCE_ABSENCE_VALUES, } from "./core/tool-policy.js";
139
+ export { createAllowDenyPolicy, createApprovalPolicy, COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, findUnverifiableRecursiveDelete, combinePolicies, decisionText, resolveAsk, toolPolicyNameSets, type ToolPolicyNameSets, type NamedToolPolicy, type ToolPolicyProjection, type ToolPolicyProjectionComponent, type ConstraintChainEntry, checkToolPolicyProjection, constraintChainEntryOf, constraintChainDigest, type ToolPolicy, type ToolCallRequest, type PermissionResult, type DecisionReason, type ApprovalSettledBy, APPROVAL_SETTLED_BY_VALUES, isApprovalSettledBy, type AskDenyResolution, ASK_DENY_RESOLUTION_VALUES, isAskDenyResolution, screenApproverAttribution, APPROVER_ATTRIBUTION_MAX_CHARS, type OnAsk, type AskOutcome, type ResolvedAsk, type AskRequest, type AskDelegationProvenance, type AskRuleEvidence, type AskEvidenceAbsence, ASK_EVIDENCE_ABSENCE_VALUES, } from "./core/tool-policy.js";
140
140
  export { parseAutoModeResponse, createAutoModeDecider, type AutoModeVerdict, type AutoModeDecider, type AutoModeDeciderOptions, type AutoModeClassifyFn, type AutoModeClassifyInput, } from "./core/auto-mode.js";
141
141
  export { buildAutoModePrompt, renderAutoModeWindow, renderAutoModeAction, AUTO_MODE_DEFAULTS_SENTINEL, type AutoModeRules, type BuildAutoModePromptOptions, type AutoModeWindowOptions, } from "./core/auto-mode-prompt.js";
142
142
  export { AUTO_MODE_BASE_PROMPT, AUTO_MODE_PERMISSIONS_EXTERNAL } from "./core/auto-mode-prompt-assets.js";
package/dist/index.js CHANGED
@@ -113,7 +113,7 @@ export { hasScheduler, isValidCronExpr, SchedulerError } from "./core/scheduler.
113
113
  export { createSchedulerTools, SCHEDULE_WAKEUP_TOOL_NAME, AUTONOMOUS_LOOP_SENTINEL, AUTONOMOUS_LOOP_DYNAMIC_SENTINEL, } from "./tools/scheduler-tools.js";
114
114
  export { resolveAutonomousLoopPrompt, AUTONOMOUS_LOOP_PREAMBLE, AUTONOMOUS_LOOP_PREAMBLE_PERSISTENT, } from "./tools/loop-tick.js";
115
115
  export { tightenTaskSpec, TaskSpecTightenError } from "./core/tighten-task-spec.js";
116
- export { createAllowDenyPolicy, createApprovalPolicy, COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, findUnverifiableRecursiveDelete, combinePolicies, decisionText, resolveAsk, toolPolicyNameSets, checkToolPolicyProjection, constraintChainEntryOf, constraintChainDigest, APPROVAL_SETTLED_BY_VALUES, isApprovalSettledBy, screenApproverAttribution, APPROVER_ATTRIBUTION_MAX_CHARS, ASK_EVIDENCE_ABSENCE_VALUES, } from "./core/tool-policy.js";
116
+ export { createAllowDenyPolicy, createApprovalPolicy, COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, findUnverifiableRecursiveDelete, combinePolicies, decisionText, resolveAsk, toolPolicyNameSets, checkToolPolicyProjection, constraintChainEntryOf, constraintChainDigest, APPROVAL_SETTLED_BY_VALUES, isApprovalSettledBy, ASK_DENY_RESOLUTION_VALUES, isAskDenyResolution, screenApproverAttribution, APPROVER_ATTRIBUTION_MAX_CHARS, ASK_EVIDENCE_ABSENCE_VALUES, } from "./core/tool-policy.js";
117
117
  export { parseAutoModeResponse, createAutoModeDecider, } from "./core/auto-mode.js";
118
118
  export { buildAutoModePrompt, renderAutoModeWindow, renderAutoModeAction, AUTO_MODE_DEFAULTS_SENTINEL, } from "./core/auto-mode-prompt.js";
119
119
  export { AUTO_MODE_BASE_PROMPT, AUTO_MODE_PERMISSIONS_EXTERNAL } from "./core/auto-mode-prompt-assets.js";
@@ -78,7 +78,16 @@ export interface WebFetchGrounding {
78
78
  export declare function htmlToText(html: string): string;
79
79
  export declare function webFetchToolSpec(config?: WebFetchConfig): ToolSpec;
80
80
  /** Defined (harness) form of web_fetch — back-compat for direct execution / tests. For `spec.tools`, use the raw
81
- * {@link webFetchToolSpec} (prepare-task defineTool-wraps spec.tools entries). */
81
+ * {@link webFetchToolSpec} (prepare-task defineTool-wraps spec.tools entries).
82
+ *
83
+ * ⚠️ CTX-DEPENDENT BEHAVIOR: this factory takes no `enrichCtx`, so the product it returns is sealed
84
+ * around the pre-RB-409 minimal `{toolCallId, signal}` ctx. Fed into `TaskSpec.tools`, a finished
85
+ * product is mounted AS-IS (prepare-task's brand branch — there is no ctx seat on
86
+ * `AgentTool.execute` to inject through), so every `ctx.*` read inside {@link webFetchToolSpec}
87
+ * reads absent — including `reminderMark`/`reminderDisclosureCounts`, i.e. the design/319 exact-mark
88
+ * DEFUSE and its observation counters silently do not run for that mount. Two ways to keep them:
89
+ * hand `spec.tools` the raw {@link webFetchToolSpec} (the Runner then wraps it with its own trusted
90
+ * ctx builder), or call `defineTool(webFetchToolSpec(config), { enrichCtx })` yourself. */
82
91
  export declare function createWebFetchTool(config?: WebFetchConfig): AgentTool;
83
92
  /** {@link createWebFetchSummarizer}'s truncation bound — CC's `MAX_MARKDOWN_LENGTH`
84
93
  * (WebFetchTool/utils.ts:128). Exported so a deployment wiring the reference summarizer doesn't need
package/dist/tools/web.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool, errorResult } from "../core/tools.js";
3
3
  import { delimitUntrusted, inlineUntrusted } from "../core/untrusted-text.js";
4
- import { discloseReminderShaped } from "../core/reminder-disclosure.js";
4
+ import { discloseReminderShaped, observeReminderMarkEcho } from "../core/reminder-disclosure.js";
5
5
  import { redactSecrets } from "../core/untrusted-egress.js";
6
6
  import { isPrivateHost } from "../core/runner/image.js";
7
7
  import { binaryMagicFormat } from "./fs/safety.js";
@@ -493,6 +493,7 @@ export function webFetchToolSpec(config = {}) {
493
493
  const content = (excerpt
494
494
  ? `${headline}\nThe error response body${trimmed.length > excerpt.length ? ` (first ${ERROR_BODY_EXCERPT_CHARS} chars)` : ""} follows:\n\n${delimitUntrusted(`WebFetch ${parsed.hostname}`, excerpt)}`
495
495
  : headline) + bodyStateNote + promptNote;
496
+ observeReminderMarkEcho({ text: content, mark: ctx.reminderMark, outlet: "webFetch", counts: ctx.reminderDisclosureCounts });
496
497
  return {
497
498
  content,
498
499
  details: {
@@ -908,9 +909,9 @@ export function createWebSearchTool(config) {
908
909
  const msg = redactSecrets(e instanceof Error ? e.message : String(e)).trim();
909
910
  const headline = "Error (WebSearch): the search backend failed.";
910
911
  const verdict = classifySearchFailure(msg);
911
- return errorResult((msg
912
- ? `${headline} The backend's error text follows:\n\n${delimitUntrusted("WebSearch backend error", msg, SEARCH_ERROR_EXCERPT_CHARS)}`
913
- : headline) + `\n\n${verdict.hint}`, failCard({ retryable: verdict.retryable }));
912
+ const composed = (msg ? `${headline} The backend's error text follows:\n\n${delimitUntrusted("WebSearch backend error", msg, SEARCH_ERROR_EXCERPT_CHARS)}` : headline) + `\n\n${verdict.hint}`;
913
+ observeReminderMarkEcho({ text: composed, mark: ctx.reminderMark, outlet: "webSearch", counts: ctx.reminderDisclosureCounts });
914
+ return errorResult(composed, failCard({ retryable: verdict.retryable }));
914
915
  }
915
916
  finally {
916
917
  clearTimeout(timer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.50.0",
3
+ "version": "5.52.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_comment": "design/87 L3 — frozen public export surface of src/index.ts (name -> kind). DO NOT edit by hand to silence a red test. A removed/changed entry = a SemVer-BREAKING change; bump MAJOR and update this fixture in the SAME commit (design/87 §4.2 / §5.2). Regenerate via REGEN in test/export-surface.test.ts.",
3
- "count": 1646,
3
+ "count": 1649,
4
4
  "exports": {
5
5
  "A2ATaskState": "type",
6
6
  "A2ATaskStateReversal": "type",
@@ -21,6 +21,7 @@
21
21
  "APPROVAL_SETTLED_BY_VALUES": "variable",
22
22
  "APPROVER_ATTRIBUTION_MAX_CHARS": "variable",
23
23
  "ARTIFACT_LIMITS": "variable",
24
+ "ASK_DENY_RESOLUTION_VALUES": "variable",
24
25
  "ASK_EVIDENCE_ABSENCE_VALUES": "variable",
25
26
  "AUTONOMOUS_LOOP_DYNAMIC_SENTINEL": "variable",
26
27
  "AUTONOMOUS_LOOP_PREAMBLE": "variable",
@@ -59,6 +60,7 @@
59
60
  "ArtifactVerifyResult": "type",
60
61
  "AskAnswerContinuationSource": "type",
61
62
  "AskDelegationProvenance": "interface",
63
+ "AskDenyResolution": "type",
62
64
  "AskEffective": "type",
63
65
  "AskEvidenceAbsence": "type",
64
66
  "AskOutcome": "type",
@@ -1371,6 +1373,7 @@
1371
1373
  "inlineUntrusted": "function",
1372
1374
  "inspectDegenerate": "function",
1373
1375
  "isApprovalSettledBy": "function",
1376
+ "isAskDenyResolution": "function",
1374
1377
  "isDelegatedAgentTerminal": "function",
1375
1378
  "isInstructionEntry": "function",
1376
1379
  "isIsolated": "function",