@ai-sdk/harness 1.0.149 → 1.0.151

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/harness",
3
- "version": "1.0.149",
3
+ "version": "1.0.151",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -46,7 +46,7 @@
46
46
  "dependencies": {
47
47
  "@ai-sdk/provider": "4.0.27",
48
48
  "@ai-sdk/provider-utils": "5.0.59",
49
- "ai": "7.0.138"
49
+ "ai": "7.0.140"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "ws": "^8.21.0",
@@ -58,7 +58,7 @@
58
58
  }
59
59
  },
60
60
  "devDependencies": {
61
- "@ai-sdk/otel": "1.0.138",
61
+ "@ai-sdk/otel": "1.0.140",
62
62
  "@opentelemetry/sdk-trace-base": "2.7.1",
63
63
  "@types/node": "22.19.19",
64
64
  "@types/ws": "^8.5.13",
@@ -14,6 +14,9 @@ import type {
14
14
  * locally to find the original tool call before it can resume the paused turn.
15
15
  * Responses that already have a tool result are ignored, because those
16
16
  * approvals were already consumed by a prior continuation.
17
+ * An `execution-denied` result is synthesized by `convertToModelMessages`
18
+ * for a newly denied approval; it does not mean the harness has received
19
+ * the decision, so that approval response must still continue the turn.
17
20
  */
18
21
  export function collectHarnessAgentToolApprovalContinuations(input: {
19
22
  messages: readonly ModelMessage[];
@@ -38,7 +41,10 @@ export function collectHarnessAgentToolApprovalContinuations(input: {
38
41
 
39
42
  const toolResultIds = new Set<string>();
40
43
  for (const part of lastMessage.content) {
41
- if (part.type === 'tool-result') {
44
+ if (
45
+ part.type === 'tool-result' &&
46
+ part.output.type !== 'execution-denied'
47
+ ) {
42
48
  toolResultIds.add(part.toolCallId);
43
49
  }
44
50
  }
@@ -4,6 +4,7 @@ import {
4
4
  type AssistantModelMessage,
5
5
  type Context,
6
6
  type ToolModelMessage,
7
+ type ToolResultOutput,
7
8
  type ToolSet,
8
9
  } from '@ai-sdk/provider-utils';
9
10
  import {
@@ -11,6 +12,7 @@ import {
11
12
  asLanguageModelUsage,
12
13
  createAsyncIterableStream,
13
14
  createNullLanguageModelUsage,
15
+ createToolModelOutput,
14
16
  DefaultStepResult,
15
17
  toResponseMessages,
16
18
  } from 'ai/internal';
@@ -22,6 +24,7 @@ import {
22
24
  createUIMessageStreamResponse,
23
25
  toUIMessageStream as toUIMessageStreamHelper,
24
26
  type CallWarning,
27
+ type Citation,
25
28
  type ContentPart,
26
29
  type FinishReason,
27
30
  type InferUIMessageChunk,
@@ -53,6 +56,19 @@ type EnrichedStreamPart<TOOLS extends ToolSet, PARTIAL_OUTPUT> = {
53
56
  partialOutput: PARTIAL_OUTPUT | undefined;
54
57
  };
55
58
 
59
+ type AccumulatedTextContent = {
60
+ type: 'text';
61
+ text: string;
62
+ citations?: Citation[];
63
+ providerMetadata?: ProviderMetadata;
64
+ };
65
+
66
+ type AccumulatedReasoningContent = {
67
+ type: 'reasoning';
68
+ text: string;
69
+ providerMetadata?: ProviderMetadata;
70
+ };
71
+
56
72
  /**
57
73
  * Concrete `StreamTextResult` implementation backed by a single
58
74
  * harness prompt turn.
@@ -149,11 +165,17 @@ export class HarnessStreamTextResult<
149
165
 
150
166
  private readonly stepsBuffer: StepResult<TOOLS, RUNTIME_CONTEXT>[] = [];
151
167
  private currentStepContent: ContentPart<TOOLS>[] = [];
168
+ private currentStepTextContent = new Map<string, AccumulatedTextContent>();
169
+ private currentStepReasoningContent = new Map<
170
+ string,
171
+ AccumulatedReasoningContent
172
+ >();
152
173
  private currentStepWarnings: CallWarning[] = [];
153
174
  private stepNumber = 0;
154
175
  private stepStarted = false;
155
176
 
156
177
  private readonly tools: TOOLS;
178
+ private readonly toolModelOutputs = new Map<string, ToolResultOutput>();
157
179
  private readonly runtimeContext: RUNTIME_CONTEXT;
158
180
  private readonly toolsContext: InferToolSetContext<TOOLS>;
159
181
  private readonly providerName: string;
@@ -169,6 +191,13 @@ export class HarnessStreamTextResult<
169
191
  private aggregateWarnings: CallWarning[] = [];
170
192
  private settled = false;
171
193
 
194
+ setToolModelOutput(input: {
195
+ toolCallId: string;
196
+ output: ToolResultOutput;
197
+ }): void {
198
+ this.toolModelOutputs.set(input.toolCallId, input.output);
199
+ }
200
+
172
201
  constructor(options: {
173
202
  tools: TOOLS;
174
203
  runtimeContext: RUNTIME_CONTEXT;
@@ -261,6 +290,8 @@ export class HarnessStreamTextResult<
261
290
  */
262
291
  discardCurrentStepContent(): void {
263
292
  this.currentStepContent = [];
293
+ this.currentStepTextContent.clear();
294
+ this.currentStepReasoningContent.clear();
264
295
  this.currentStepWarnings = [];
265
296
  this.stepStarted = false;
266
297
  }
@@ -334,6 +365,8 @@ export class HarnessStreamTextResult<
334
365
 
335
366
  this.stepNumber += 1;
336
367
  this.currentStepContent = [];
368
+ this.currentStepTextContent.clear();
369
+ this.currentStepReasoningContent.clear();
337
370
  this.currentStepWarnings = [];
338
371
  this.stepStarted = false;
339
372
 
@@ -421,14 +454,12 @@ export class HarnessStreamTextResult<
421
454
  >,
422
455
  );
423
456
  this._text.resolve(finalStep.text);
424
- // Reasoning content parts are not yet derived from harness events; the
425
- // foundation surfaces an empty array. Adapters that emit reasoning
426
- // deltas can be wired up to produce real reasoning content in a later
427
- // pass.
428
457
  this._reasoning.resolve(
429
- [] as StreamProp<TOOLS, RUNTIME_CONTEXT, OUTPUT, 'reasoning'>,
458
+ finalStep.content.filter(
459
+ part => part.type === 'reasoning' || part.type === 'reasoning-file',
460
+ ) as StreamProp<TOOLS, RUNTIME_CONTEXT, OUTPUT, 'reasoning'>,
430
461
  );
431
- this._reasoningText.resolve(undefined);
462
+ this._reasoningText.resolve(finalStep.reasoningText);
432
463
  this._files.resolve(this.stepsBuffer.flatMap(s => s.files));
433
464
  this._sources.resolve(this.stepsBuffer.flatMap(s => s.sources));
434
465
  this._toolCalls.resolve(
@@ -491,10 +522,29 @@ export class HarnessStreamTextResult<
491
522
  this._response.resolve(finalStep.response);
492
523
  this._providerMetadata.resolve(this.finalProviderMetadata);
493
524
 
494
- const responseMessages = await toResponseMessages<TOOLS>({
495
- content: aggregatedContent,
496
- tools: this.tools,
497
- });
525
+ const responseTools: ToolSet = {};
526
+ for (const [name, tool] of Object.entries(this.tools)) {
527
+ responseTools[name] = {
528
+ ...tool,
529
+ toModelOutput: (options: {
530
+ toolCallId: string;
531
+ input: unknown;
532
+ output: unknown;
533
+ }) =>
534
+ this.toolModelOutputs.get(options.toolCallId) ??
535
+ createToolModelOutput({ ...options, tool, errorMode: 'none' }),
536
+ };
537
+ }
538
+ const responseMessages: Array<AssistantModelMessage | ToolModelMessage> =
539
+ [];
540
+ for (const step of this.stepsBuffer) {
541
+ responseMessages.push(
542
+ ...(await toResponseMessages<TOOLS>({
543
+ content: step.content,
544
+ tools: responseTools as TOOLS,
545
+ })),
546
+ );
547
+ }
498
548
  this._responseMessages.resolve(responseMessages);
499
549
 
500
550
  // Forward AI SDK finish event before closing.
@@ -755,6 +805,8 @@ export class HarnessStreamTextResult<
755
805
  toUIMessageStream<UI_MESSAGE extends UIMessage>({
756
806
  originalMessages,
757
807
  generateMessageId,
808
+ onStepEnd,
809
+ onStepFinish,
758
810
  onEnd,
759
811
  onFinish,
760
812
  messageMetadata,
@@ -772,6 +824,8 @@ export class HarnessStreamTextResult<
772
824
  tools: this.tools,
773
825
  originalMessages,
774
826
  generateMessageId,
827
+ onStepEnd,
828
+ onStepFinish,
775
829
  onEnd,
776
830
  onFinish,
777
831
  messageMetadata,
@@ -795,6 +849,8 @@ export class HarnessStreamTextResult<
795
849
  toUIMessageStreamResponse<UI_MESSAGE extends UIMessage>({
796
850
  originalMessages,
797
851
  generateMessageId,
852
+ onStepEnd,
853
+ onStepFinish,
798
854
  onEnd,
799
855
  onFinish,
800
856
  messageMetadata,
@@ -813,6 +869,8 @@ export class HarnessStreamTextResult<
813
869
  stream: this.toUIMessageStream<UI_MESSAGE>({
814
870
  originalMessages,
815
871
  generateMessageId,
872
+ onStepEnd,
873
+ onStepFinish,
816
874
  onEnd,
817
875
  onFinish,
818
876
  messageMetadata,
@@ -834,30 +892,88 @@ export class HarnessStreamTextResult<
834
892
 
835
893
  private appendToCurrentStepContent(part: TextStreamPart<TOOLS>): void {
836
894
  switch (part.type) {
895
+ case 'text-start': {
896
+ const content: AccumulatedTextContent = {
897
+ type: 'text',
898
+ text: '',
899
+ ...(part.providerMetadata != null
900
+ ? { providerMetadata: part.providerMetadata }
901
+ : {}),
902
+ };
903
+ this.currentStepTextContent.set(part.id, content);
904
+ this.currentStepContent.push(content);
905
+ return;
906
+ }
837
907
  case 'text-delta': {
838
- // Coalesce contiguous text-deltas with the same id into one text part.
839
- const last =
840
- this.currentStepContent[this.currentStepContent.length - 1];
841
- if (last && last.type === 'text') {
842
- (last as { text: string }).text += part.text;
843
- } else {
844
- this.currentStepContent.push({
908
+ let content = this.currentStepTextContent.get(part.id);
909
+ if (content == null) {
910
+ content = {
845
911
  type: 'text',
846
- text: part.text,
847
- } as ContentPart<TOOLS>);
912
+ text: '',
913
+ ...(part.providerMetadata != null
914
+ ? { providerMetadata: part.providerMetadata }
915
+ : {}),
916
+ };
917
+ this.currentStepTextContent.set(part.id, content);
918
+ this.currentStepContent.push(content);
919
+ }
920
+ content.text += part.text;
921
+ if (part.providerMetadata != null) {
922
+ content.providerMetadata = part.providerMetadata;
923
+ }
924
+ return;
925
+ }
926
+ case 'text-end': {
927
+ const content = this.currentStepTextContent.get(part.id);
928
+ if (content != null) {
929
+ if (part.providerMetadata != null) {
930
+ content.providerMetadata = part.providerMetadata;
931
+ }
932
+ if (part.citations != null) {
933
+ content.citations = part.citations;
934
+ }
935
+ this.currentStepTextContent.delete(part.id);
848
936
  }
849
937
  return;
850
938
  }
939
+ case 'reasoning-start': {
940
+ const content: AccumulatedReasoningContent = {
941
+ type: 'reasoning',
942
+ text: '',
943
+ ...(part.providerMetadata != null
944
+ ? { providerMetadata: part.providerMetadata }
945
+ : {}),
946
+ };
947
+ this.currentStepReasoningContent.set(part.id, content);
948
+ this.currentStepContent.push(content);
949
+ return;
950
+ }
851
951
  case 'reasoning-delta': {
852
- const last =
853
- this.currentStepContent[this.currentStepContent.length - 1];
854
- if (last && last.type === 'reasoning') {
855
- (last as { text: string }).text += part.text;
856
- } else {
857
- this.currentStepContent.push({
952
+ let content = this.currentStepReasoningContent.get(part.id);
953
+ if (content == null) {
954
+ content = {
858
955
  type: 'reasoning',
859
- text: part.text,
860
- } as ContentPart<TOOLS>);
956
+ text: '',
957
+ ...(part.providerMetadata != null
958
+ ? { providerMetadata: part.providerMetadata }
959
+ : {}),
960
+ };
961
+ this.currentStepReasoningContent.set(part.id, content);
962
+ this.currentStepContent.push(content);
963
+ }
964
+ content.text += part.text;
965
+ if (part.providerMetadata != null) {
966
+ content.providerMetadata = part.providerMetadata;
967
+ }
968
+ return;
969
+ }
970
+ case 'reasoning-end': {
971
+ const content = this.currentStepReasoningContent.get(part.id);
972
+ if (content != null) {
973
+ if (part.providerMetadata != null) {
974
+ content.providerMetadata = part.providerMetadata;
975
+ }
976
+ this.currentStepReasoningContent.delete(part.id);
861
977
  }
862
978
  return;
863
979
  }
@@ -24,6 +24,7 @@ import {
24
24
  type Experimental_SandboxSession as SandboxSession,
25
25
  type ToolApprovalResponse,
26
26
  type ToolResultPart,
27
+ type ToolResultOutput,
27
28
  type ToolSet,
28
29
  } from '@ai-sdk/provider-utils';
29
30
  import {
@@ -36,6 +37,7 @@ import {
36
37
  addLanguageModelUsage,
37
38
  asLanguageModelUsage,
38
39
  createNullLanguageModelUsage,
40
+ createToolModelOutput,
39
41
  parseToolCall,
40
42
  validateToolContext,
41
43
  } from 'ai/internal';
@@ -68,6 +70,8 @@ import {
68
70
  import { resolveCustomToolApproval } from './permission-mode';
69
71
  import { logBridgeError } from '../../utils/bridge-diagnostics';
70
72
  import { pinSandboxChannelEventCheckpoint } from '../../utils/sandbox-channel';
73
+ import { normalizeHarnessToolModelOutput } from '../../utils/normalize-harness-tool-model-output';
74
+ import { HarnessCapabilityUnsupportedError } from '../../errors/harness-capability-unsupported-error';
71
75
 
72
76
  const invalidToolInputMessage = 'Tool input validation failed.';
73
77
 
@@ -303,12 +307,15 @@ export function runPrompt<
303
307
  >();
304
308
  /*
305
309
  * Host results are echoed back as `tool-result` events too, so the event
306
- * alone cannot say who ran the tool — classify by the originating
307
- * `tool-call`. Only an explicit `true` counts: `LanguageModelV4ToolCall`
308
- * defines an omitted flag as client-executed. A call missing from the map
309
- * arrived in an earlier slice, where nothing here can classify it.
310
+ * alone cannot provide the parsed input or say who ran the tool — restore
311
+ * both from the originating `tool-call`. Only an explicit `true` counts:
312
+ * `LanguageModelV4ToolCall` defines an omitted flag as client-executed. A
313
+ * call missing from the map arrived in an earlier slice, where nothing
314
+ * here can classify it.
310
315
  */
311
316
  const translateOptions = {
317
+ getToolCall: (toolCallId: string) =>
318
+ toolCallsByToolCallId.get(toolCallId),
312
319
  isProviderExecuted: (toolCallId: string): boolean => {
313
320
  const rawToolCall = rawToolCallsByToolCallId.get(toolCallId);
314
321
  return rawToolCall == null || rawToolCall.providerExecuted === true;
@@ -421,9 +428,24 @@ export function runPrompt<
421
428
  toolExecutions.clear();
422
429
  bufferedToolOutcomes = [];
423
430
  };
424
- let expectedStepToolCallCount: number | undefined;
425
- let observedStepToolCallCount = 0;
426
- let pauseAfterStepToolCalls = false;
431
+ type FinishStepPart = Extract<HarnessV1StreamPart, { type: 'finish-step' }>;
432
+ /*
433
+ * An approval request or a pending client tool result does not end the
434
+ * response right away: the rest of the step (other tool calls, native
435
+ * results, host tool executions) still has to be surfaced. The pause
436
+ * happens at the next `finish-step`, once every host tool started in
437
+ * this response has been echoed back by the runtime.
438
+ */
439
+ let hostInputPauseRequested = false;
440
+ let deferredPauseFinishStep: FinishStepPart | undefined;
441
+ /*
442
+ * A `finish-step` that arrives while host tools started in this step have
443
+ * not been echoed back is the adapter signalling that the runtime waits on
444
+ * host input, not the end of the step. It is remembered so the step can
445
+ * still be closed if the turn finishes without another `finish-step`.
446
+ */
447
+ let ignoredFinishStep: FinishStepPart | undefined;
448
+ const hostToolCallIdsAwaitingEcho = new Set<string>();
427
449
  const buildModelCallContent = (): ContentPart<TOOLS>[] => {
428
450
  const parts: ContentPart<TOOLS>[] = [];
429
451
  if (stepText) parts.push({ type: 'text', text: stepText });
@@ -441,9 +463,7 @@ export function runPrompt<
441
463
  stepApprovalRequests = [];
442
464
  bufferedToolOutcomes = [];
443
465
  toolExecutions.clear();
444
- expectedStepToolCallCount = undefined;
445
- observedStepToolCallCount = 0;
446
- pauseAfterStepToolCalls = false;
466
+ ignoredFinishStep = undefined;
447
467
  };
448
468
  const zeroUsage: LanguageModelV4Usage = {
449
469
  inputTokens: {
@@ -484,25 +504,27 @@ export function runPrompt<
484
504
  resetStepContent();
485
505
  return step;
486
506
  };
487
- const finishForHostInputPause = async (options: {
488
- completeCurrentStep: boolean;
489
- }): Promise<void> => {
507
+ const finishForHostInputPause = async (): Promise<void> => {
490
508
  await waitForOutstandingHostToolExecutions();
491
- if (options.completeCurrentStep) {
492
- await completeStep({
493
- finishReason: toolCallsFinishReason,
494
- usage: zeroUsage,
495
- providerMetadata: undefined,
496
- });
497
- } else {
498
- await publishToolExecutions();
499
- }
509
+ await publishToolExecutions();
500
510
  await lifecycle.end({
501
511
  steps: completedSteps,
502
512
  usage: asLanguageModelUsage(zeroUsage),
503
513
  });
504
514
  await result.finish();
505
515
  };
516
+ const pauseForHostInput = async (
517
+ finishStep: FinishStepPart | undefined,
518
+ ): Promise<void> => {
519
+ await waitForOutstandingHostToolExecutions();
520
+ closingResumedStep = false;
521
+ await completeStep({
522
+ finishReason: toolCallsFinishReason,
523
+ usage: finishStep?.usage ?? zeroUsage,
524
+ providerMetadata: finishStep?.harnessMetadata,
525
+ });
526
+ await finishForHostInputPause();
527
+ };
506
528
  const enqueueApprovalRequest = (approval: {
507
529
  approvalId: string;
508
530
  toolCall: ToolCallTextStreamPart;
@@ -609,7 +631,37 @@ export function runPrompt<
609
631
  };
610
632
  const submitToolResult: HarnessV1PromptControl['submitToolResult'] =
611
633
  async submission => {
634
+ if (submission.toolResult != null) {
635
+ let output: ToolResultOutput;
636
+ try {
637
+ output = normalizeHarnessToolModelOutput({
638
+ output: submission.toolResult.output,
639
+ });
640
+ } catch (error) {
641
+ if (
642
+ !continuationsByToolCallId.has(submission.toolCallId) ||
643
+ !HarnessCapabilityUnsupportedError.isInstance(error)
644
+ ) {
645
+ throw error;
646
+ }
647
+ output = {
648
+ type: 'text',
649
+ value: JSON.stringify(submission.output) ?? 'null',
650
+ };
651
+ }
652
+ submission = {
653
+ ...submission,
654
+ toolResult: { ...submission.toolResult, output },
655
+ };
656
+ result.setToolModelOutput({
657
+ toolCallId: submission.toolCallId,
658
+ output,
659
+ });
660
+ }
612
661
  if (!input.isTurnSuspending?.()) {
662
+ if (!settledHostToolCallIds.has(submission.toolCallId)) {
663
+ hostToolCallIdsAwaitingEcho.add(submission.toolCallId);
664
+ }
613
665
  return control.submitToolResult(submission);
614
666
  }
615
667
  const toolCall =
@@ -808,7 +860,7 @@ export function runPrompt<
808
860
  });
809
861
  if (!execution.executed) {
810
862
  recordPendingToolResult({ toolCall: rawToolCall });
811
- await finishForHostInputPause({ completeCurrentStep: false });
863
+ await finishForHostInputPause();
812
864
  return 'awaiting-tool-result';
813
865
  }
814
866
  const toolExecution = toolExecutions.get(rawToolCall.toolCallId)!;
@@ -852,11 +904,10 @@ export function runPrompt<
852
904
 
853
905
  while (true) {
854
906
  if (
855
- pauseAfterStepToolCalls &&
856
- expectedStepToolCallCount != null &&
857
- observedStepToolCallCount >= expectedStepToolCallCount
907
+ deferredPauseFinishStep != null &&
908
+ hostToolCallIdsAwaitingEcho.size === 0
858
909
  ) {
859
- await finishForHostInputPause({ completeCurrentStep: true });
910
+ await pauseForHostInput(deferredPauseFinishStep);
860
911
  return;
861
912
  }
862
913
 
@@ -874,6 +925,9 @@ export function runPrompt<
874
925
  result.enqueueRaw(value.rawValue);
875
926
  continue;
876
927
  }
928
+ if (value.type === 'tool-result') {
929
+ hostToolCallIdsAwaitingEcho.delete(value.toolCallId);
930
+ }
877
931
 
878
932
  if (pendingStopBoundary != null) {
879
933
  if (value.type === 'finish') {
@@ -964,6 +1018,45 @@ export function runPrompt<
964
1018
  if (settledHostInputReplay || settledBuiltinApprovalReplay) {
965
1019
  continue;
966
1020
  }
1021
+ if (value.type === 'finish-step') {
1022
+ if (hostInputPauseRequested) {
1023
+ await waitForOutstandingHostToolExecutions();
1024
+ if (hostToolCallIdsAwaitingEcho.size === 0) {
1025
+ await pauseForHostInput(value);
1026
+ return;
1027
+ }
1028
+ deferredPauseFinishStep = value;
1029
+ continue;
1030
+ }
1031
+ if (hostToolCallIdsAwaitingEcho.size > 0) {
1032
+ ignoredFinishStep = value;
1033
+ continue;
1034
+ }
1035
+ ignoredFinishStep = undefined;
1036
+ }
1037
+ if (value.type === 'finish') {
1038
+ if (hostInputPauseRequested) {
1039
+ await pauseForHostInput(deferredPauseFinishStep);
1040
+ return;
1041
+ }
1042
+ if (ignoredFinishStep != null) {
1043
+ const finishStep = ignoredFinishStep;
1044
+ ignoredFinishStep = undefined;
1045
+ await waitForOutstandingHostToolExecutions();
1046
+ if (closingResumedStep) {
1047
+ closingResumedStep = false;
1048
+ await publishToolExecutions();
1049
+ resetStepContent();
1050
+ result.discardCurrentStepContent();
1051
+ } else {
1052
+ await completeStep({
1053
+ finishReason: finishStep.finishReason,
1054
+ usage: finishStep.usage,
1055
+ providerMetadata: finishStep.harnessMetadata,
1056
+ });
1057
+ }
1058
+ }
1059
+ }
967
1060
  if (displayValue.type === 'finish-step' && closingResumedStep) {
968
1061
  closingResumedStep = false;
969
1062
  await publishToolExecutions();
@@ -994,6 +1087,12 @@ export function runPrompt<
994
1087
  `Harness '${input.harness.harnessId}' emitted a built-in tool approval request but does not support approval responses.`,
995
1088
  );
996
1089
  }
1090
+ /*
1091
+ * The runtime may report a step boundary while it waits for this
1092
+ * decision. The decision is answered here, so that boundary must
1093
+ * not end the step before the denied call's result arrives.
1094
+ */
1095
+ hostToolCallIdsAwaitingEcho.add(displayValue.toolCallId);
997
1096
  await control.submitToolApproval({
998
1097
  approvalId: displayValue.approvalId,
999
1098
  approved: false,
@@ -1100,8 +1199,6 @@ export function runPrompt<
1100
1199
  }
1101
1200
 
1102
1201
  if (value.type === 'tool-call') {
1103
- observedStepToolCallCount += 1;
1104
- expectedStepToolCallCount ??= value.stepToolCallCount;
1105
1202
  const toolCall = toolCallsByToolCallId.get(value.toolCallId);
1106
1203
  if (toolCall != null) {
1107
1204
  stepToolCalls.push(toolCall as ContentPart<TOOLS>);
@@ -1201,8 +1298,8 @@ export function runPrompt<
1201
1298
  approvalId: pendingApproval.approvalId,
1202
1299
  toolCall,
1203
1300
  });
1204
- await finishForHostInputPause({ completeCurrentStep: true });
1205
- return;
1301
+ hostInputPauseRequested = true;
1302
+ continue;
1206
1303
  }
1207
1304
 
1208
1305
  // Drive step boundaries.
@@ -1270,15 +1367,8 @@ export function runPrompt<
1270
1367
  }
1271
1368
  if (isClientExecutedBuiltin) {
1272
1369
  recordPendingToolResult({ toolCall });
1273
- if (
1274
- expectedStepToolCallCount != null &&
1275
- observedStepToolCallCount < expectedStepToolCallCount
1276
- ) {
1277
- pauseAfterStepToolCalls = true;
1278
- continue;
1279
- }
1280
- await finishForHostInputPause({ completeCurrentStep: true });
1281
- return;
1370
+ hostInputPauseRequested = true;
1371
+ continue;
1282
1372
  }
1283
1373
  if (validatedHostToolCall == null) {
1284
1374
  throw new Error(
@@ -1393,28 +1483,15 @@ export function runPrompt<
1393
1483
  approvalId: pendingApproval.approvalId,
1394
1484
  toolCall: pendingParsedToolCall,
1395
1485
  });
1396
- if (
1397
- expectedStepToolCallCount != null &&
1398
- observedStepToolCallCount < expectedStepToolCallCount
1399
- ) {
1400
- pauseAfterStepToolCalls = true;
1401
- continue;
1402
- }
1403
- await finishForHostInputPause({ completeCurrentStep: true });
1404
- return;
1486
+ hostInputPauseRequested = true;
1487
+ continue;
1405
1488
  }
1406
1489
  if (!isExecutableTool(activeTools[toolCall.toolName])) {
1407
1490
  recordPendingToolResult({ toolCall });
1408
- if (
1409
- expectedStepToolCallCount != null &&
1410
- observedStepToolCallCount < expectedStepToolCallCount
1411
- ) {
1412
- pauseAfterStepToolCalls = true;
1413
- continue;
1414
- }
1415
- await finishForHostInputPause({ completeCurrentStep: true });
1416
- return;
1491
+ hostInputPauseRequested = true;
1492
+ continue;
1417
1493
  }
1494
+ hostToolCallIdsAwaitingEcho.add(toolCall.toolCallId);
1418
1495
  startHostToolExecution(
1419
1496
  (async () => {
1420
1497
  const toolExecution = toolExecutions.get(toolCall.toolCallId);
@@ -1481,6 +1558,10 @@ export function runPrompt<
1481
1558
  );
1482
1559
  }
1483
1560
  }
1561
+ if (hostInputPauseRequested && input.isTurnSuspending?.() !== true) {
1562
+ await pauseForHostInput(deferredPauseFinishStep);
1563
+ return;
1564
+ }
1484
1565
  await waitForOutstandingHostToolExecutions();
1485
1566
  const isTurnSuspending = input.isTurnSuspending?.() === true;
1486
1567
  if (isTurnSuspending) {
@@ -1695,6 +1776,22 @@ async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
1695
1776
  await input.submitToolResult({
1696
1777
  toolCallId: input.event.toolCallId,
1697
1778
  output,
1779
+ ...(tool.toModelOutput == null
1780
+ ? {}
1781
+ : {
1782
+ toolResult: {
1783
+ type: 'tool-result' as const,
1784
+ toolCallId: input.event.toolCallId,
1785
+ toolName: input.event.toolName,
1786
+ output: await createToolModelOutput({
1787
+ toolCallId: input.event.toolCallId,
1788
+ input: input.parsedToolCall.input,
1789
+ output,
1790
+ tool,
1791
+ errorMode: 'none',
1792
+ }),
1793
+ },
1794
+ }),
1698
1795
  });
1699
1796
  return { executed: true, outcome: { ok: true, output } };
1700
1797
  } catch (err) {