@sema-agent/core 5.50.0 → 5.51.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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.50.0",
3
+ "version": "5.51.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",