@vellumai/assistant 0.12.2-dev.202609162214.308bda0 → 0.12.2-dev.202609162314.7778bc8

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.
@@ -24,7 +24,7 @@ The desktop client bypasses personal-browser discovery, extension reconnect wait
24
24
 
25
25
  Tab IDs are ephemeral numeric aliases for this managed browser's CDP target IDs. They are never personal extension tab IDs. Initial attachment selects an existing HTTP(S) page or opens a blank tab. Use `tabs list` and `tabs select --tab-id <id>` to choose explicitly. Navigation, tab selection, document replacement and release clear the desktop snapshot map. Personal-browser snapshots use a separate namespace.
26
26
 
27
- CDP mouse events use page viewport CSS coordinates. Before dispatching them, the client animates a purple arrow overlay to the same point. The overlay is excluded from accessibility and hit testing. It appears in the existing desktop stream and is removed on release. It does not move the OS pointer, appear in browser toolbar UI or visualize every programmatic DOM operation. Native dialogs and other applications are outside the browser CLI; users can interact with them directly in the expanded desktop. The picture-in-picture preview is view-only. Direct user interaction does not pause automation.
27
+ CDP mouse events use page viewport CSS coordinates. Before dispatching them, the client animates a purple arrow overlay to the same point. The overlay is excluded from accessibility and hit testing. It appears in the existing desktop stream and is removed on release. It does not move the OS pointer, appear in browser toolbar UI or visualize every programmatic DOM operation. Native dialogs and other applications are outside the browser CLI; users can interact with them directly in the expanded desktop. The picture-in-picture preview is view-only. Direct user interaction does not pause automation. For logins, the assistant uses saved credentials first and securely collects missing credentials with `assistant credentials prompt`, then fills the login form itself. Every CAPTCHA and bot-detection challenge, including sliders and press-and-hold checks, is handed to the user before the assistant attempts it. When human interaction is needed, the assistant calls `ask_question` with `desktopHelp: { message, doneLabel, skipLabel }` in the user's language. The tool releases held browser input and reserves the desktop before publishing a question with `presentation: "virtual_desktop"`. The live viewer mounts only in the attended Electron window, with browser focus as a fallback on older shells and the web, so conversations release it when focus changes. A busy viewer offers Reconnect if the previous connection has not finished closing. The compact card lives in the scrolling message transcript, with expandable instructions and a preview capped at 384 pixels wide. It hosts the shared live viewer, with Step In opening its full desktop modal and Done or Skip resolving the existing question response. The assistant retains an exclusive human-help reservation while waiting. Done resumes the same conversation's automation lease for its fresh snapshot; Skip, closure, timeout and cancellation release it. The browser idle timer does not expire a pending human-help reservation. The live preview connects after Show live preview or Step In, and reuses an already-open picture-in-picture viewer. Other devices receiving the card do not automatically open a stream. When the request resolves, its interactive viewer closes or returns to the read-only PiP session that was open before the request. Skip does not imply the obstacle was resolved. Older clients show the same request as a normal Done/Skip question, with model-localized labels retained in history. Channels without dynamic UI return guidance to continue in the app rather than waiting on an unusable card.
28
28
 
29
29
  The client records key and mouse presses before dispatch. On release it opens a fresh bounded cleanup connection, attaches to the same live targets, releases uncertain held input and removes overlays. A failed cleanup preserves state and the automation slot for retry. Turn-triggered cancellation retries cleanup automatically until it succeeds or ownership changes. Dispatched actions are never automatically retried. Closed targets need no input cleanup. Browser-process loss disposes the client, and later requests discover the replacement process.
30
30
 
package/openapi.yaml CHANGED
@@ -21322,6 +21322,9 @@ paths:
21322
21322
  additionalProperties: false
21323
21323
  freeTextPlaceholder:
21324
21324
  type: string
21325
+ presentation:
21326
+ type: string
21327
+ const: virtual_desktop
21325
21328
  required:
21326
21329
  - id
21327
21330
  - question
@@ -21364,6 +21367,9 @@ paths:
21364
21367
  additionalProperties: false
21365
21368
  freeTextPlaceholder:
21366
21369
  type: string
21370
+ presentation:
21371
+ type: string
21372
+ const: virtual_desktop
21367
21373
  required:
21368
21374
  - id
21369
21375
  - question
@@ -21833,6 +21839,9 @@ paths:
21833
21839
  additionalProperties: false
21834
21840
  freeTextPlaceholder:
21835
21841
  type: string
21842
+ presentation:
21843
+ type: string
21844
+ const: virtual_desktop
21836
21845
  required:
21837
21846
  - id
21838
21847
  - question
@@ -21875,6 +21884,9 @@ paths:
21875
21884
  additionalProperties: false
21876
21885
  freeTextPlaceholder:
21877
21886
  type: string
21887
+ presentation:
21888
+ type: string
21889
+ const: virtual_desktop
21878
21890
  required:
21879
21891
  - id
21880
21892
  - question
@@ -25728,6 +25740,9 @@ paths:
25728
25740
  additionalProperties: false
25729
25741
  freeTextPlaceholder:
25730
25742
  type: string
25743
+ presentation:
25744
+ type: string
25745
+ const: virtual_desktop
25731
25746
  required:
25732
25747
  - id
25733
25748
  - question
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609162214.308bda0",
3
+ "version": "0.12.2-dev.202609162314.7778bc8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -2550,6 +2550,141 @@ describe("AgentLoop", () => {
2550
2550
  expect(noRetryNudge).toBeUndefined();
2551
2551
  });
2552
2552
 
2553
+ // A native web search left deferred by the same assistant turn (a
2554
+ // server_tool_use with no result, alongside the client tool call) runs on
2555
+ // the next request only if that request's tool-result message holds
2556
+ // tool_result blocks alone. The coaching then rides inside the errored
2557
+ // tool_result instead of as a trailing text block.
2558
+ test("folds retry coaching into the tool_result when the assistant turn left a server tool deferred", async () => {
2559
+ const mixedTurn: ProviderResponse = {
2560
+ content: [
2561
+ {
2562
+ type: "tool_use",
2563
+ id: "t1",
2564
+ name: "read_file",
2565
+ input: { path: "/missing.txt" },
2566
+ },
2567
+ {
2568
+ type: "server_tool_use",
2569
+ id: "srvtoolu_1",
2570
+ name: "web_search",
2571
+ input: { query: "news" },
2572
+ },
2573
+ ],
2574
+ model: "mock-model",
2575
+ usage: { inputTokens: 10, outputTokens: 5 },
2576
+ stopReason: "tool_use",
2577
+ };
2578
+ const { provider, calls } = createMockProvider([
2579
+ mixedTurn,
2580
+ textResponse("Reported the missing file."),
2581
+ ]);
2582
+ const toolExecutor = async () => ({
2583
+ content: "Error: HTTP 404",
2584
+ isError: true,
2585
+ });
2586
+
2587
+ const loop = new AgentLoop({
2588
+ provider,
2589
+ systemPrompt: "system",
2590
+ conversationId: "test-conversation",
2591
+ tools: dummyTools,
2592
+ toolExecutor,
2593
+ });
2594
+ await loop.run({
2595
+ requestId: "test-request",
2596
+ messages: [userMessage],
2597
+ onEvent: () => {},
2598
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
2599
+ });
2600
+
2601
+ expect(calls).toHaveLength(2);
2602
+ const followUpMessages = calls[1].messages;
2603
+ const followUp = followUpMessages[followUpMessages.length - 1];
2604
+ expect(followUp.role).toBe("user");
2605
+ expect(followUp.content.map((b) => b.type)).toEqual(["tool_result"]);
2606
+ const erroredToolResult = followUp.content[0] as Extract<
2607
+ ContentBlock,
2608
+ { type: "tool_result" }
2609
+ >;
2610
+ expect(erroredToolResult.is_error).toBe(true);
2611
+ expect(erroredToolResult.content).toContain("Error: HTTP 404");
2612
+ expect(erroredToolResult.content).toContain("looks recoverable");
2613
+ });
2614
+
2615
+ // A deferred search answered by a message with text after the results
2616
+ // (history assembled elsewhere) is rejected by the provider as unpaired.
2617
+ // The ordering-repair retry stamps the synthetic result rather than
2618
+ // re-sending the same history.
2619
+ test("repairs a deferred search rejected as unpaired instead of re-sending the same history", async () => {
2620
+ const mixedTurn: Message = {
2621
+ role: "assistant",
2622
+ content: [
2623
+ {
2624
+ type: "tool_use",
2625
+ id: "t1",
2626
+ name: "read_file",
2627
+ input: { path: "/a" },
2628
+ },
2629
+ {
2630
+ type: "server_tool_use",
2631
+ id: "srvtoolu_1",
2632
+ name: "web_search",
2633
+ input: { query: "news" },
2634
+ },
2635
+ ],
2636
+ };
2637
+ const answeredWithText: Message = {
2638
+ role: "user",
2639
+ content: [
2640
+ {
2641
+ type: "tool_result",
2642
+ tool_use_id: "t1",
2643
+ content: "Error: HTTP 404",
2644
+ is_error: true,
2645
+ },
2646
+ { type: "text", text: "<system_notice>retry</system_notice>" },
2647
+ ],
2648
+ };
2649
+ const { provider, calls } = createMockProvider([
2650
+ new Error(
2651
+ "Anthropic API error (400): messages.1: `web_search` tool use with id `srvtoolu_1` was found without a corresponding `web_search_tool_result` block",
2652
+ ),
2653
+ textResponse("recovered"),
2654
+ ]);
2655
+ const loop = new AgentLoop({
2656
+ provider,
2657
+ systemPrompt: "system",
2658
+ conversationId: "test-conversation",
2659
+ });
2660
+ const events: AgentEvent[] = [];
2661
+
2662
+ const { history } = await loop.run({
2663
+ requestId: "test-request",
2664
+ messages: [userMessage, mixedTurn, answeredWithText],
2665
+ onEvent: collectEvents(events),
2666
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
2667
+ });
2668
+
2669
+ expect(calls).toHaveLength(2);
2670
+ expect(events.filter((e) => e.type === "error")).toHaveLength(0);
2671
+ // Deep repair stamped the synthetic result on the orphaned search, and the
2672
+ // outbound sanitizer then summarized the answered pair as text, so the
2673
+ // retry carries no unanswered server_tool_use.
2674
+ expect(calls[1].messages[1].content.map((b) => b.type)).toEqual([
2675
+ "tool_use",
2676
+ "text",
2677
+ ]);
2678
+ expect(calls[1].messages[1].content[1]).toMatchObject({
2679
+ type: "text",
2680
+ text: expect.stringContaining("results unavailable"),
2681
+ });
2682
+ expect(history[history.length - 1]).toEqual({
2683
+ role: "assistant",
2684
+ content: [{ type: "text", text: "recovered" }],
2685
+ });
2686
+ });
2687
+
2553
2688
  // Retry coaching stops after a tool fails 3 times in a row — past that the
2554
2689
  // error is likely unrecoverable and further coaching only burns tokens.
2555
2690
  test("stops appending retry coaching after 3 consecutive failures of a tool", async () => {
@@ -1650,6 +1650,61 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
1650
1650
  ).toHaveLength(0);
1651
1651
  });
1652
1652
 
1653
+ test("mixed tail answered by a tool_result plus trailing text gets a synthetic web_search_tool_result", async () => {
1654
+ // Text after the client result closes the assistant turn on the provider
1655
+ // side, which then rejects the unanswered search as unpaired. The search
1656
+ // is an orphan here, so the synthetic error result keeps the request
1657
+ // valid at the cost of that one search.
1658
+ const messages: Message[] = [
1659
+ userMsg("Do things"),
1660
+ {
1661
+ role: "assistant",
1662
+ content: [
1663
+ { type: "tool_use", id: "tu_a", name: "web_fetch", input: {} },
1664
+ {
1665
+ type: "server_tool_use",
1666
+ id: "srvtoolu_b",
1667
+ name: "web_search",
1668
+ input: { query: "test" },
1669
+ },
1670
+ ],
1671
+ },
1672
+ {
1673
+ role: "user",
1674
+ content: [
1675
+ {
1676
+ type: "tool_result",
1677
+ tool_use_id: "tu_a",
1678
+ content: "Error: HTTP 404",
1679
+ is_error: true,
1680
+ },
1681
+ { type: "text", text: "<system_notice>retry</system_notice>" },
1682
+ ],
1683
+ },
1684
+ ];
1685
+ await provider.sendMessage(messages);
1686
+
1687
+ const sent = lastStreamParams!.messages as Array<{
1688
+ role: string;
1689
+ content: Array<{ type: string; tool_use_id?: string }>;
1690
+ }>;
1691
+
1692
+ // The repaired pair leaves the mixed message: ensureToolPairing keeps the
1693
+ // client tool_result adjacent to its tool_use and moves the now-answered
1694
+ // server pair into its own assistant turn, ahead of the trailing text.
1695
+ expect(sent.map((m) => m.content.map((b) => b.type))).toEqual([
1696
+ ["text"],
1697
+ ["tool_use"],
1698
+ ["tool_result"],
1699
+ ["server_tool_use", "web_search_tool_result"],
1700
+ ["text"],
1701
+ ]);
1702
+ expect(sent[3].content[1]).toMatchObject({
1703
+ type: "web_search_tool_result",
1704
+ tool_use_id: "srvtoolu_b",
1705
+ });
1706
+ });
1707
+
1653
1708
  test("deferred mixed heartbeat shape with text and multiple searches goes out verbatim", async () => {
1654
1709
  const messages: Message[] = [
1655
1710
  userMsg("Heartbeat: check the file and the news"),
@@ -643,6 +643,59 @@ describe("repairHistory", () => {
643
643
  ]);
644
644
  });
645
645
 
646
+ test("deferred mixed tail answered by a tool_result plus trailing text gets a synthetic result", () => {
647
+ // Text after the client result closes the assistant turn on the provider
648
+ // side, which then rejects the unanswered search as unpaired. The search
649
+ // is an orphan here, so the synthetic error result keeps the request
650
+ // valid at the cost of that one search. `deepRepairHistory` is the loop's
651
+ // recovery path for that rejection.
652
+ const messages: Message[] = [
653
+ { role: "user", content: [{ type: "text", text: "Check both" }] },
654
+ {
655
+ role: "assistant",
656
+ content: [
657
+ { type: "tool_use", id: "tu_a", name: "web_fetch", input: {} },
658
+ {
659
+ type: "server_tool_use",
660
+ id: "stu_deferred",
661
+ name: "web_search",
662
+ input: { query: "news" },
663
+ },
664
+ ],
665
+ },
666
+ {
667
+ role: "user",
668
+ content: [
669
+ {
670
+ type: "tool_result",
671
+ tool_use_id: "tu_a",
672
+ content: "Error: HTTP 404",
673
+ is_error: true,
674
+ },
675
+ { type: "text", text: "<system_notice>retry</system_notice>" },
676
+ ],
677
+ },
678
+ ];
679
+
680
+ const { messages: repaired, stats } = deepRepairHistory(messages);
681
+
682
+ expect(stats.missingToolResultsInserted).toBe(1);
683
+ expect(repaired).toHaveLength(3);
684
+ expect(repaired[1].content.map((b) => b.type)).toEqual([
685
+ "tool_use",
686
+ "server_tool_use",
687
+ "web_search_tool_result",
688
+ ]);
689
+ expect(repaired[1].content[2]).toMatchObject({
690
+ type: "web_search_tool_result",
691
+ tool_use_id: "stu_deferred",
692
+ });
693
+ expect(repaired[2].content.map((b) => b.type)).toEqual([
694
+ "tool_result",
695
+ "text",
696
+ ]);
697
+ });
698
+
646
699
  test("cross-message server tool pair from a completed deferred execution survives a reload untouched", () => {
647
700
  const messages: Message[] = [
648
701
  { role: "user", content: [{ type: "text", text: "Check both" }] },
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { analyzeServerToolPairing } from "../providers/server-tool-pairing.js";
4
+ import type { ContentBlock, Message } from "../providers/types.js";
5
+
6
+ const DEFERRED_ID = "srvtoolu_deferred";
7
+
8
+ const opener: Message = {
9
+ role: "user",
10
+ content: [{ type: "text", text: "Check both" }],
11
+ };
12
+
13
+ /** Assistant turn calling a client tool and a native web search together. */
14
+ const mixedTail: Message = {
15
+ role: "assistant",
16
+ content: [
17
+ { type: "tool_use", id: "tu_a", name: "web_fetch", input: {} },
18
+ {
19
+ type: "server_tool_use",
20
+ id: DEFERRED_ID,
21
+ name: "web_search",
22
+ input: { query: "news" },
23
+ },
24
+ ],
25
+ };
26
+
27
+ const clientResult: ContentBlock = {
28
+ type: "tool_result",
29
+ tool_use_id: "tu_a",
30
+ content: "Error: HTTP 404",
31
+ is_error: true,
32
+ };
33
+
34
+ const guidance: ContentBlock = {
35
+ type: "text",
36
+ text: "<system_notice>retry</system_notice>",
37
+ };
38
+
39
+ describe("analyzeServerToolPairing", () => {
40
+ test("an unanswered use with nothing after it is deferred", () => {
41
+ const pairing = analyzeServerToolPairing([opener, mixedTail]);
42
+
43
+ expect(pairing.deferredUseIds).toEqual(new Set([DEFERRED_ID]));
44
+ expect(pairing.resolvedPairIds.size).toBe(0);
45
+ });
46
+
47
+ test("an unanswered use answered by tool_result blocks alone is deferred", () => {
48
+ const pairing = analyzeServerToolPairing([
49
+ opener,
50
+ mixedTail,
51
+ { role: "user", content: [clientResult] },
52
+ ]);
53
+
54
+ expect(pairing.deferredUseIds).toEqual(new Set([DEFERRED_ID]));
55
+ });
56
+
57
+ test("guidance text after the tool_result makes the use an orphan, not a deferral", () => {
58
+ // The trailing text closes the assistant turn on the provider side, which
59
+ // then rejects the unanswered use as unpaired; the repair passes stamp a
60
+ // synthetic result instead of preserving it.
61
+ const pairing = analyzeServerToolPairing([
62
+ opener,
63
+ mixedTail,
64
+ { role: "user", content: [clientResult, guidance] },
65
+ ]);
66
+
67
+ expect(pairing.deferredUseIds.size).toBe(0);
68
+ expect(pairing.resolvedPairIds.size).toBe(0);
69
+ });
70
+
71
+ test("a user text message after the tool_result message makes the use an orphan", () => {
72
+ const pairing = analyzeServerToolPairing([
73
+ opener,
74
+ mixedTail,
75
+ { role: "user", content: [clientResult] },
76
+ { role: "user", content: [{ type: "text", text: "also do this" }] },
77
+ ]);
78
+
79
+ expect(pairing.deferredUseIds.size).toBe(0);
80
+ });
81
+
82
+ test("a use answered in its own message is resolved and never deferred", () => {
83
+ const pairing = analyzeServerToolPairing([
84
+ opener,
85
+ {
86
+ role: "assistant",
87
+ content: [
88
+ {
89
+ type: "server_tool_use",
90
+ id: "srvtoolu_done",
91
+ name: "web_search",
92
+ input: { query: "news" },
93
+ },
94
+ {
95
+ type: "web_search_tool_result",
96
+ tool_use_id: "srvtoolu_done",
97
+ content: [],
98
+ },
99
+ ],
100
+ },
101
+ { role: "user", content: [guidance] },
102
+ ]);
103
+
104
+ expect(pairing.resolvedPairIds).toEqual(new Set(["srvtoolu_done"]));
105
+ expect(pairing.deferredUseIds.size).toBe(0);
106
+ });
107
+ });
@@ -0,0 +1,99 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { buildToolResultFollowUp } from "../agent/tool-result-follow-up.js";
4
+ import type { ContentBlock, Message, TextContent } from "../providers/types.js";
5
+
6
+ const opener: Message = {
7
+ role: "user",
8
+ content: [{ type: "text", text: "Check both" }],
9
+ };
10
+
11
+ /** Assistant turn calling two client tools and a native web search together. */
12
+ const mixedTurn: Message = {
13
+ role: "assistant",
14
+ content: [
15
+ { type: "tool_use", id: "tu_a", name: "web_fetch", input: {} },
16
+ { type: "tool_use", id: "tu_b", name: "file_read", input: {} },
17
+ {
18
+ type: "server_tool_use",
19
+ id: "srvtoolu_deferred",
20
+ name: "web_search",
21
+ input: { query: "news" },
22
+ },
23
+ ],
24
+ };
25
+
26
+ /** The same turn without the search: nothing is deferred. */
27
+ const clientOnlyTurn: Message = {
28
+ role: "assistant",
29
+ content: mixedTurn.content.filter((b) => b.type === "tool_use"),
30
+ };
31
+
32
+ const erroredResult: ContentBlock = {
33
+ type: "tool_result",
34
+ tool_use_id: "tu_a",
35
+ content: "Error: HTTP 404",
36
+ is_error: true,
37
+ };
38
+
39
+ const okResult: ContentBlock = {
40
+ type: "tool_result",
41
+ tool_use_id: "tu_b",
42
+ content: "file contents",
43
+ is_error: false,
44
+ };
45
+
46
+ const guidance: TextContent = {
47
+ type: "text",
48
+ text: "<system_notice>retry</system_notice>",
49
+ };
50
+
51
+ describe("buildToolResultFollowUp", () => {
52
+ test("returns the results untouched when there is no guidance", () => {
53
+ const results = [erroredResult, okResult];
54
+
55
+ expect(buildToolResultFollowUp([opener, mixedTurn], results, [])).toBe(
56
+ results,
57
+ );
58
+ });
59
+
60
+ test("appends guidance as separate blocks when no server tool is deferred", () => {
61
+ const followUp = buildToolResultFollowUp(
62
+ [opener, clientOnlyTurn],
63
+ [erroredResult, okResult],
64
+ [guidance],
65
+ );
66
+
67
+ expect(followUp).toEqual([erroredResult, okResult, guidance]);
68
+ });
69
+
70
+ test("folds guidance into the errored tool_result when a server tool is deferred", () => {
71
+ const followUp = buildToolResultFollowUp(
72
+ [opener, mixedTurn],
73
+ [erroredResult, okResult],
74
+ [guidance],
75
+ );
76
+
77
+ expect(followUp.map((b) => b.type)).toEqual(["tool_result", "tool_result"]);
78
+ expect(followUp[0]).toEqual({
79
+ ...erroredResult,
80
+ content: `Error: HTTP 404\n\n${guidance.text}`,
81
+ });
82
+ expect(followUp[1]).toBe(okResult);
83
+ });
84
+
85
+ test("falls back to the last tool_result when none errored", () => {
86
+ const followUp = buildToolResultFollowUp(
87
+ [opener, mixedTurn],
88
+ [okResult, { ...okResult, tool_use_id: "tu_a" }],
89
+ [guidance],
90
+ );
91
+
92
+ expect(followUp.map((b) => b.type)).toEqual(["tool_result", "tool_result"]);
93
+ expect(followUp[0]).toBe(okResult);
94
+ expect(followUp[1]).toMatchObject({
95
+ tool_use_id: "tu_a",
96
+ content: `file contents\n\n${guidance.text}`,
97
+ });
98
+ });
99
+ });
package/src/agent/loop.ts CHANGED
@@ -42,6 +42,7 @@ import type {
42
42
  Provider,
43
43
  ProviderResponse,
44
44
  SendMessageOptions,
45
+ TextContent,
45
46
  ToolDefinition,
46
47
  ToolResultContent,
47
48
  } from "../providers/types.js";
@@ -74,6 +75,7 @@ import {
74
75
  isRepairableOrderingError,
75
76
  isUserTerminalHistoryError,
76
77
  } from "./history-repair/history-repair.js";
78
+ import { buildToolResultFollowUp } from "./tool-result-follow-up.js";
77
79
 
78
80
  const log = getLogger("agent-loop");
79
81
 
@@ -1558,7 +1560,7 @@ export class AgentLoop {
1558
1560
  turn: number,
1559
1561
  ): Promise<{
1560
1562
  resultBlocks: ContentBlock[];
1561
- additionalContextBlocks: ContentBlock[];
1563
+ additionalContextBlocks: TextContent[];
1562
1564
  }> => {
1563
1565
  if (conversationDir) {
1564
1566
  const toolCallByUseId = new Map(
@@ -1583,7 +1585,7 @@ export class AgentLoop {
1583
1585
  180_000;
1584
1586
 
1585
1587
  const resultBlocks: ContentBlock[] = [];
1586
- const additionalContextBlocks: ContentBlock[] = [];
1588
+ const additionalContextBlocks: TextContent[] = [];
1587
1589
  for (const block of rawBlocks) {
1588
1590
  if (block.type !== "tool_result") {
1589
1591
  resultBlocks.push(block);
@@ -2964,16 +2966,20 @@ export class AgentLoop {
2964
2966
 
2965
2967
  toolUseTurns++;
2966
2968
 
2967
- // Append any guidance a post-tool-use hook surfaced via
2968
- // `additionalContext` (e.g. tool-error retry coaching) as separate
2969
- // blocks. They join the provider-bound history below but were not part
2970
- // of the tool_result events emitted above, so the model sees the
2971
- // guidance while the client-facing and persisted tool output stay the
2972
- // tool's actual result.
2973
- resultBlocks.push(...additionalContextBlocks);
2974
-
2975
- // Add tool results as a user message and continue the loop.
2976
- history.push({ role: "user", content: resultBlocks });
2969
+ // Add the tool results, plus any guidance a post-tool-use hook
2970
+ // surfaced via `additionalContext` (e.g. tool-error retry coaching),
2971
+ // as a user message and continue the loop. The guidance joins the
2972
+ // provider-bound history only: it was not part of the tool_result
2973
+ // events emitted above, so the client-facing and persisted tool
2974
+ // output stay the tool's actual result.
2975
+ history.push({
2976
+ role: "user",
2977
+ content: buildToolResultFollowUp(
2978
+ history,
2979
+ resultBlocks,
2980
+ additionalContextBlocks,
2981
+ ),
2982
+ });
2977
2983
 
2978
2984
  // Invoke checkpoint callback after tool results are in history.
2979
2985
  // Handoff takes precedence over the budget gate: a handoff decision
@@ -0,0 +1,70 @@
1
+ // Content of the user message that answers a tool-calling assistant turn:
2
+ // the tool_result blocks plus any guidance a post-tool-use hook surfaced via
3
+ // `additionalContext`.
4
+
5
+ import { analyzeServerToolPairing } from "../providers/server-tool-pairing.js";
6
+ import type {
7
+ ContentBlock,
8
+ Message,
9
+ TextContent,
10
+ ToolResultContent,
11
+ } from "../providers/types.js";
12
+
13
+ /**
14
+ * Hook guidance rides as separate blocks after the results, so the model sees
15
+ * it while each tool_result stays the tool's actual output. The exception is
16
+ * an assistant turn that left a server tool (native web search) deferred: the
17
+ * provider runs a deferred server tool only when the answering message holds
18
+ * tool_result blocks alone, and any block after the results closes the
19
+ * assistant turn, after which the provider rejects the unanswered server tool
20
+ * as unpaired. The guidance is then folded into the content of the last
21
+ * errored tool_result (the last tool_result when none errored), where the
22
+ * model still sees it and the search still runs.
23
+ */
24
+ export function buildToolResultFollowUp(
25
+ history: ReadonlyArray<Message>,
26
+ resultBlocks: ContentBlock[],
27
+ additionalContextBlocks: ReadonlyArray<TextContent>,
28
+ ): ContentBlock[] {
29
+ if (additionalContextBlocks.length === 0) {
30
+ return resultBlocks;
31
+ }
32
+ const targetIndex = foldTargetIndex(resultBlocks);
33
+ const pairing = analyzeServerToolPairing([
34
+ ...history,
35
+ { role: "user", content: resultBlocks },
36
+ ]);
37
+ if (pairing.deferredUseIds.size === 0 || targetIndex < 0) {
38
+ return [...resultBlocks, ...additionalContextBlocks];
39
+ }
40
+ const target = resultBlocks[targetIndex] as ToolResultContent;
41
+ const folded: ToolResultContent = {
42
+ ...target,
43
+ content: [target.content, ...additionalContextBlocks.map((b) => b.text)]
44
+ .filter((text) => text.length > 0)
45
+ .join("\n\n"),
46
+ };
47
+ return resultBlocks.map((block, index) =>
48
+ index === targetIndex ? folded : block,
49
+ );
50
+ }
51
+
52
+ /** Index of the last errored tool_result, else the last tool_result, else -1. */
53
+ function foldTargetIndex(blocks: ReadonlyArray<ContentBlock>): number {
54
+ let lastResult = -1;
55
+ for (let index = blocks.length - 1; index >= 0; index--) {
56
+ const block = blocks[index];
57
+ // guard:allow-tool-result-only: the answering user message carries client
58
+ // tool_result blocks only; server-side results live in assistant messages.
59
+ if (block.type !== "tool_result") {
60
+ continue;
61
+ }
62
+ if (block.is_error) {
63
+ return index;
64
+ }
65
+ if (lastResult < 0) {
66
+ lastResult = index;
67
+ }
68
+ }
69
+ return lastResult;
70
+ }