@vellumai/assistant 0.11.2-dev.202608050309.fa8263f → 0.11.2-dev.202608050507.7e832db

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": "@vellumai/assistant",
3
- "version": "0.11.2-dev.202608050309.fa8263f",
3
+ "version": "0.11.2-dev.202608050507.7e832db",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1410,9 +1410,11 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
1410
1410
  expect(sent[2].content[0].type).toBe("text");
1411
1411
  });
1412
1412
 
1413
- test("orphaned server_tool_use at end of messages gets synthetic result (no synthetic user append)", async () => {
1414
- // Orphaned server_tool_use at the end should get a synthetic
1415
- // web_search_tool_result but no synthetic user message.
1413
+ test("unanswered server_tool_use in the final assistant message is left intact for deferred execution", async () => {
1414
+ // When the final assistant message ends with an unanswered
1415
+ // server_tool_use, the API executes the search on this request (the
1416
+ // pause_turn / deferred-execution contract). The tail must go back
1417
+ // verbatim with no synthetic result, or the search is cancelled.
1416
1418
  const messages: Message[] = [
1417
1419
  userMsg("Search something"),
1418
1420
  {
@@ -1434,12 +1436,11 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
1434
1436
  content: Array<{ type: string; tool_use_id?: string }>;
1435
1437
  }>;
1436
1438
 
1437
- // Original 2 messages, with synthetic result injected in assistant message
1439
+ // Original 2 messages, assistant tail verbatim: no synthetic result
1438
1440
  expect(sent).toHaveLength(2);
1439
1441
  expect(sent[1].role).toBe("assistant");
1442
+ expect(sent[1].content).toHaveLength(1);
1440
1443
  expect(sent[1].content[0].type).toBe("server_tool_use");
1441
- expect(sent[1].content[1].type).toBe("web_search_tool_result");
1442
- expect(sent[1].content[1].tool_use_id).toBe("srvtoolu_end");
1443
1444
  });
1444
1445
 
1445
1446
  test("server_tool_use with matching web_search_tool_result passes through unchanged", async () => {
@@ -1568,7 +1569,11 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
1568
1569
  expect(webSearchResults[0].tool_use_id).toBe("srvtoolu_search");
1569
1570
  });
1570
1571
 
1571
- test("mixed tool_use and server_tool_use — only client-side tool_use gets pairing, server tools pass through", async () => {
1572
+ test("mixed tool_use and deferred server_tool_use tail goes out verbatim", async () => {
1573
+ // The mixed parallel group is the deferred-execution trigger: the API
1574
+ // returns stop_reason tool_use without running the search, and runs it on
1575
+ // the next request when the client tool results come back with the
1576
+ // assistant message unchanged. No split, no synthetic result.
1572
1577
  const messages: Message[] = [
1573
1578
  userMsg("Do things"),
1574
1579
  {
@@ -1583,7 +1588,7 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
1583
1588
  },
1584
1589
  ],
1585
1590
  },
1586
- // Only tu_a has a result — server_tool_use doesn't need one in the user message
1591
+ // Only tu_a has a result; the deferred search has none yet
1587
1592
  toolResultMsg("tu_a", "result A"),
1588
1593
  ];
1589
1594
  await provider.sendMessage(messages);
@@ -1597,27 +1602,139 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
1597
1602
  }>;
1598
1603
  }>;
1599
1604
 
1600
- // Assistant message should have tool_use in paired portion, server_tool_use in carryover
1601
- // ensureToolPairing splits: paired = [tool_use(tu_a)], carryover = [server_tool_use(srvtoolu_b)]
1602
- // Result: assistant(tool_use) → user(tool_result) → assistant(server_tool_use) → user(synthetic_continuation)
1605
+ // assistant(tool_use, server_tool_use) → user(tool_result), verbatim
1606
+ expect(sent).toHaveLength(3);
1603
1607
  const assistantMsg = sent[1];
1604
1608
  expect(assistantMsg.role).toBe("assistant");
1605
- expect(assistantMsg.content[0].type).toBe("tool_use");
1609
+ expect(assistantMsg.content.map((b) => b.type)).toEqual([
1610
+ "tool_use",
1611
+ "server_tool_use",
1612
+ ]);
1606
1613
 
1607
1614
  const userAfterAssistant = sent[2];
1608
1615
  expect(userAfterAssistant.role).toBe("user");
1609
- // Only tool_result for tu_a — no synthetic web_search_tool_result
1610
1616
  expect(userAfterAssistant.content[0]).toMatchObject({
1611
1617
  type: "tool_result",
1612
1618
  tool_use_id: "tu_a",
1613
1619
  });
1614
1620
 
1615
- // server_tool_use preserved in a carryover assistant message with synthetic result
1616
- const carryoverAssistant = sent[3];
1617
- expect(carryoverAssistant.role).toBe("assistant");
1618
- expect(carryoverAssistant.content[0].type).toBe("server_tool_use");
1619
- expect(carryoverAssistant.content[1].type).toBe("web_search_tool_result");
1620
- expect(carryoverAssistant.content[1].tool_use_id).toBe("srvtoolu_b");
1621
+ // No synthetic web_search_tool_result anywhere; the API runs the search
1622
+ const allBlocks = sent.flatMap((m) => m.content);
1623
+ expect(
1624
+ allBlocks.filter((b) => b.type === "web_search_tool_result"),
1625
+ ).toHaveLength(0);
1626
+ });
1627
+
1628
+ test("deferred mixed heartbeat shape with text and multiple searches goes out verbatim", async () => {
1629
+ const messages: Message[] = [
1630
+ userMsg("Heartbeat: check the file and the news"),
1631
+ {
1632
+ role: "assistant",
1633
+ content: [
1634
+ { type: "text", text: "Checking both." },
1635
+ { type: "tool_use", id: "tu_read", name: "file_read", input: {} },
1636
+ {
1637
+ type: "server_tool_use",
1638
+ id: "srvtoolu_s1",
1639
+ name: "web_search",
1640
+ input: { query: "news one" },
1641
+ },
1642
+ {
1643
+ type: "server_tool_use",
1644
+ id: "srvtoolu_s2",
1645
+ name: "web_search",
1646
+ input: { query: "news two" },
1647
+ },
1648
+ ],
1649
+ },
1650
+ toolResultMsg("tu_read", "file contents"),
1651
+ ];
1652
+ await provider.sendMessage(messages);
1653
+
1654
+ const sent = lastStreamParams!.messages as Array<{
1655
+ role: string;
1656
+ content: Array<{ type: string; id?: string; tool_use_id?: string }>;
1657
+ }>;
1658
+
1659
+ expect(sent).toHaveLength(3);
1660
+ expect(sent[1].content.map((b) => b.type)).toEqual([
1661
+ "text",
1662
+ "tool_use",
1663
+ "server_tool_use",
1664
+ "server_tool_use",
1665
+ ]);
1666
+ const allBlocks = sent.flatMap((m) => m.content);
1667
+ expect(
1668
+ allBlocks.filter((b) => b.type === "web_search_tool_result"),
1669
+ ).toHaveLength(0);
1670
+ });
1671
+
1672
+ test("cross-message server tool pair from a completed deferred execution is preserved verbatim", async () => {
1673
+ // After a deferred search executes, history carries the split pair: the
1674
+ // server_tool_use in one assistant message and its web_search_tool_result
1675
+ // heading the next assistant message. Both sides must pass through
1676
+ // untouched: no synthetic result on the use, no text downgrade on the
1677
+ // result.
1678
+ const messages: Message[] = [
1679
+ userMsg("Check the file and the news"),
1680
+ {
1681
+ role: "assistant",
1682
+ content: [
1683
+ { type: "text", text: "On it." },
1684
+ { type: "tool_use", id: "tu_a", name: "file_read", input: {} },
1685
+ {
1686
+ type: "server_tool_use",
1687
+ id: "srvtoolu_deferred",
1688
+ name: "web_search",
1689
+ input: { query: "news" },
1690
+ },
1691
+ ],
1692
+ },
1693
+ toolResultMsg("tu_a", "file contents"),
1694
+ {
1695
+ role: "assistant",
1696
+ content: [
1697
+ {
1698
+ type: "web_search_tool_result",
1699
+ tool_use_id: "srvtoolu_deferred",
1700
+ content: [
1701
+ {
1702
+ type: "web_search_result",
1703
+ url: "https://example.com",
1704
+ title: "Example",
1705
+ encrypted_content: "enc_deferred",
1706
+ },
1707
+ ],
1708
+ },
1709
+ { type: "text", text: "Here is the news." },
1710
+ ],
1711
+ },
1712
+ userMsg("thanks, anything else?"),
1713
+ ];
1714
+ await provider.sendMessage(messages);
1715
+
1716
+ const sent = lastStreamParams!.messages as Array<{
1717
+ role: string;
1718
+ content: Array<{ type: string; id?: string; tool_use_id?: string }>;
1719
+ }>;
1720
+
1721
+ expect(sent).toHaveLength(5);
1722
+ expect(sent[1].content.map((b) => b.type)).toEqual([
1723
+ "text",
1724
+ "tool_use",
1725
+ "server_tool_use",
1726
+ ]);
1727
+ expect(sent[3].content.map((b) => b.type)).toEqual([
1728
+ "web_search_tool_result",
1729
+ "text",
1730
+ ]);
1731
+ // Exactly the original result block, still a result block
1732
+ const allBlocks = sent.flatMap((m) => m.content);
1733
+ const results = allBlocks.filter(
1734
+ (b) => b.type === "web_search_tool_result",
1735
+ );
1736
+ expect(results).toHaveLength(1);
1737
+ expect(results[0].tool_use_id).toBe("srvtoolu_deferred");
1621
1738
  });
1622
1739
 
1623
1740
  test("orphaned server_tool_use from interrupted stream gets repaired in multi-turn conversation", async () => {
@@ -572,8 +572,10 @@ describe("repairHistory", () => {
572
572
  ).toBe(true);
573
573
  });
574
574
 
575
- test("trailing server_tool_use gets synthetic result in same assistant message", () => {
576
- // No trailing user message needed — result goes in the assistant message
575
+ test("trailing server_tool_use is preserved as a deferred execution", () => {
576
+ // An unanswered server_tool_use at the tail is a deferred search the
577
+ // provider executes on the next request; repairing it would cancel the
578
+ // search.
577
579
  const messages: Message[] = [
578
580
  { role: "user", content: [{ type: "text", text: "Go" }] },
579
581
  {
@@ -591,21 +593,107 @@ describe("repairHistory", () => {
591
593
 
592
594
  const { messages: repaired, stats } = repairHistory(messages);
593
595
 
594
- expect(stats.missingToolResultsInserted).toBe(1);
595
- // Result is in the assistant message
596
+ expect(stats.missingToolResultsInserted).toBe(0);
596
597
  expect(repaired).toHaveLength(2);
597
598
  expect(repaired[1].role).toBe("assistant");
598
- expect(repaired[1].content).toHaveLength(2);
599
- expect(repaired[1].content[1]).toMatchObject({
600
- type: "web_search_tool_result",
601
- tool_use_id: "stu_1",
602
- content: {
603
- type: "web_search_tool_result_error",
604
- error_code: "unavailable",
605
- },
599
+ expect(repaired[1].content).toHaveLength(1);
600
+ expect(repaired[1].content[0]).toMatchObject({
601
+ type: "server_tool_use",
602
+ id: "stu_1",
606
603
  });
607
604
  });
608
605
 
606
+ test("deferred mixed tail (tool_use + server_tool_use, then tool_result) survives a reload untouched", () => {
607
+ // Persisted mid-deferral shape: the model called a client tool and a
608
+ // search in one parallel group, the client result came back, and the
609
+ // daemon reloaded before the next model call ran the search.
610
+ const messages: Message[] = [
611
+ { role: "user", content: [{ type: "text", text: "Check both" }] },
612
+ {
613
+ role: "assistant",
614
+ content: [
615
+ { type: "tool_use", id: "tu_a", name: "file_read", input: {} },
616
+ {
617
+ type: "server_tool_use",
618
+ id: "stu_deferred",
619
+ name: "web_search",
620
+ input: { query: "news" },
621
+ },
622
+ ],
623
+ },
624
+ {
625
+ role: "user",
626
+ content: [
627
+ { type: "tool_result", tool_use_id: "tu_a", content: "file" },
628
+ ],
629
+ },
630
+ ];
631
+
632
+ const { messages: repaired, stats } = repairHistory(messages);
633
+
634
+ expect(stats.missingToolResultsInserted).toBe(0);
635
+ expect(stats.orphanToolResultsDowngraded).toBe(0);
636
+ expect(repaired).toHaveLength(3);
637
+ expect(repaired[1].content.map((b) => b.type)).toEqual([
638
+ "tool_use",
639
+ "server_tool_use",
640
+ ]);
641
+ });
642
+
643
+ test("cross-message server tool pair from a completed deferred execution survives a reload untouched", () => {
644
+ const messages: Message[] = [
645
+ { role: "user", content: [{ type: "text", text: "Check both" }] },
646
+ {
647
+ role: "assistant",
648
+ content: [
649
+ { type: "tool_use", id: "tu_a", name: "file_read", input: {} },
650
+ {
651
+ type: "server_tool_use",
652
+ id: "stu_split",
653
+ name: "web_search",
654
+ input: { query: "news" },
655
+ },
656
+ ],
657
+ },
658
+ {
659
+ role: "user",
660
+ content: [
661
+ { type: "tool_result", tool_use_id: "tu_a", content: "file" },
662
+ ],
663
+ },
664
+ {
665
+ role: "assistant",
666
+ content: [
667
+ {
668
+ type: "web_search_tool_result",
669
+ tool_use_id: "stu_split",
670
+ content: [
671
+ {
672
+ type: "web_search_result",
673
+ url: "https://example.com",
674
+ title: "Example",
675
+ },
676
+ ],
677
+ },
678
+ { type: "text", text: "Found it." },
679
+ ],
680
+ },
681
+ ];
682
+
683
+ const { messages: repaired, stats } = repairHistory(messages);
684
+
685
+ expect(stats.missingToolResultsInserted).toBe(0);
686
+ expect(stats.orphanToolResultsDowngraded).toBe(0);
687
+ expect(repaired[1].content.map((b) => b.type)).toEqual([
688
+ "tool_use",
689
+ "server_tool_use",
690
+ ]);
691
+ expect(repaired[3].content.map((b) => b.type)).toEqual([
692
+ "web_search_tool_result",
693
+ "text",
694
+ ]);
695
+ });
696
+
609
697
  test("synthetic web_search_tool_result is placed immediately after its server_tool_use, not at end", () => {
610
698
  // Regression: synthetic results appended to the end of the content array
611
699
  // get separated from their server_tool_use by ensureToolPairing's split
@@ -648,6 +736,12 @@ describe("repairHistory", () => {
648
736
  },
649
737
  ],
650
738
  },
739
+ // A later assistant message closes the deferral window: the searches
740
+ // never executed, so they are genuine orphans.
741
+ {
742
+ role: "assistant",
743
+ content: [{ type: "text", text: "Skill ready." }],
744
+ },
651
745
  ];
652
746
 
653
747
  const { messages: repaired, stats } = repairHistory(messages);
@@ -866,6 +960,12 @@ describe("repairHistory", () => {
866
960
  },
867
961
  ],
868
962
  },
963
+ // A later exchange closes the deferral window for stu_missing_result.
964
+ { role: "user", content: [{ type: "text", text: "and?" }] },
965
+ {
966
+ role: "assistant",
967
+ content: [{ type: "text", text: "done" }],
968
+ },
869
969
  ];
870
970
 
871
971
  const { messages: repaired, stats } = repairHistory(messages);
@@ -328,4 +328,100 @@ describe("stripHistoricalWebSearchResults", () => {
328
328
  const afterTypes = messages[0].content.map((b) => b.type);
329
329
  expect(afterTypes).toEqual(beforeTypes);
330
330
  });
331
+
332
+ test("drops the server_tool_use of a cross-message pair from a deferred execution", () => {
333
+ // Deferred execution places the web_search_tool_result at the head of the
334
+ // assistant message after the client tool round-trip. Both sides of the
335
+ // pair must go together, or the leftover server_tool_use reads as an
336
+ // orphan and gets a synthetic error result downstream.
337
+ const messages: Message[] = [
338
+ { role: "user", content: [{ type: "text", text: "Check both" }] },
339
+ {
340
+ role: "assistant",
341
+ content: [
342
+ { type: "text", text: "On it" },
343
+ { type: "tool_use", id: "tu_read", name: "file_read", input: {} },
344
+ {
345
+ type: "server_tool_use",
346
+ id: "stu_deferred",
347
+ name: "web_search",
348
+ input: { query: "deferred query" },
349
+ },
350
+ ],
351
+ },
352
+ {
353
+ role: "user",
354
+ content: [
355
+ { type: "tool_result", tool_use_id: "tu_read", content: "file" },
356
+ ],
357
+ },
358
+ {
359
+ role: "assistant",
360
+ content: [
361
+ {
362
+ type: "web_search_tool_result",
363
+ tool_use_id: "stu_deferred",
364
+ content: [
365
+ {
366
+ type: "web_search_result",
367
+ url: "https://example.com",
368
+ title: "Deferred",
369
+ encrypted_content: "tok",
370
+ },
371
+ ],
372
+ },
373
+ { type: "text", text: "Found it." },
374
+ ],
375
+ },
376
+ ];
377
+
378
+ const { messages: result, stats } =
379
+ stripHistoricalWebSearchResults(messages);
380
+
381
+ // The use in the earlier assistant message is dropped with its result
382
+ expect(result[1].content.map((b) => b.type)).toEqual(["text", "tool_use"]);
383
+ const summarized = result[3].content[0];
384
+ expect(summarized.type).toBe("text");
385
+ expect((summarized as { text: string }).text).toContain("deferred query");
386
+ expect((summarized as { text: string }).text).toContain(
387
+ "https://example.com",
388
+ );
389
+ expect(stats.serverToolUsesDropped).toBe(1);
390
+ expect(stats.blocksStripped).toBe(1);
391
+ expect(stats.messagesModified).toBe(2);
392
+ });
393
+
394
+ test("leaves a pending server_tool_use with no result anywhere untouched", () => {
395
+ // A resultless use at the tail is a deferred search the provider executes
396
+ // on this request; stripping it would cancel the search.
397
+ const messages: Message[] = [
398
+ { role: "user", content: [{ type: "text", text: "Check both" }] },
399
+ {
400
+ role: "assistant",
401
+ content: [
402
+ { type: "tool_use", id: "tu_read", name: "file_read", input: {} },
403
+ {
404
+ type: "server_tool_use",
405
+ id: "stu_pending",
406
+ name: "web_search",
407
+ input: { query: "pending" },
408
+ },
409
+ ],
410
+ },
411
+ {
412
+ role: "user",
413
+ content: [
414
+ { type: "tool_result", tool_use_id: "tu_read", content: "file" },
415
+ ],
416
+ },
417
+ ];
418
+
419
+ const { messages: result, stats } =
420
+ stripHistoricalWebSearchResults(messages);
421
+
422
+ expect(result).toEqual(messages);
423
+ expect(stats.serverToolUsesDropped).toBe(0);
424
+ expect(stats.blocksStripped).toBe(0);
425
+ expect(stats.messagesModified).toBe(0);
426
+ });
331
427
  });
@@ -15,6 +15,7 @@
15
15
  * recover — so detection and repair of ordering drift live together as one unit.
16
16
  */
17
17
 
18
+ import { analyzeServerToolPairing } from "../../providers/server-tool-pairing.js";
18
19
  import type {
19
20
  ContentBlock,
20
21
  Message,
@@ -69,6 +70,10 @@ export function repairHistory(messages: Message[]): RepairResult {
69
70
  let pendingToolUseIds = new Set<string>();
70
71
  // tool_result blocks stripped from assistant messages, keyed by tool_use_id
71
72
  let recoveredResults = new Map<string, ToolResultContent>();
73
+ // Server-tool pairs can legitimately span messages (deferred execution) or
74
+ // sit unanswered at the tail (the provider runs the search on the next
75
+ // request), so orphanhood is judged against the whole list, not per message.
76
+ const serverToolPairing = analyzeServerToolPairing(messages);
72
77
 
73
78
  for (const [msgIndex, msg] of messages.entries()) {
74
79
  if (msg.role === "assistant") {
@@ -100,36 +105,32 @@ export function repairHistory(messages: Message[]): RepairResult {
100
105
  }
101
106
  }
102
107
 
103
- // Pair server-side tool blocks within the same assistant message.
104
- // Server tools (e.g. web_search) emit server_tool_use + matching
105
- // web_search_tool_result. Either side can go missing — the synthetic
106
- // result is inserted IMMEDIATELY AFTER the orphan server_tool_use (not
108
+ // Repair orphaned server-side tool blocks. Server tools (e.g.
109
+ // web_search) emit server_tool_use + matching web_search_tool_result;
110
+ // orphanhood comes from `serverToolPairing`, which resolves pairs
111
+ // across messages and exempts the deferred tail. The synthetic result
112
+ // is inserted IMMEDIATELY AFTER the orphan server_tool_use (not
107
113
  // appended to the end) so ensureToolPairing's split at tool_use
108
114
  // boundaries cannot separate the pair. An orphan
109
- // web_search_tool_result (no preceding server_tool_use) is downgraded
110
- // to text — Anthropic rejects the request otherwise.
111
- const serverToolIds = new Set(
112
- cleanedContent
113
- .filter(
114
- (b): b is ServerToolUseContent => b.type === "server_tool_use",
115
- )
116
- .map((b) => b.id),
117
- );
118
- const matchedServerIds = new Set(
119
- cleanedContent
120
- .filter((b) => b.type === "web_search_tool_result")
121
- .map((b) => (b as { tool_use_id: string }).tool_use_id),
122
- );
115
+ // web_search_tool_result with no use anywhere is downgraded to text.
116
+ // Anthropic rejects the request otherwise.
123
117
  const orphanedServerIds = new Set<string>();
124
- for (const id of serverToolIds) {
125
- if (!matchedServerIds.has(id)) {
126
- orphanedServerIds.add(id);
127
- }
128
- }
129
118
  const orphanedWebSearchResultIds = new Set<string>();
130
- for (const id of matchedServerIds) {
131
- if (!serverToolIds.has(id)) {
132
- orphanedWebSearchResultIds.add(id);
119
+ for (const b of cleanedContent) {
120
+ if (b.type === "server_tool_use") {
121
+ const id = (b as ServerToolUseContent).id;
122
+ if (
123
+ !serverToolPairing.resolvedPairIds.has(id) &&
124
+ !serverToolPairing.deferredUseIds.has(id)
125
+ ) {
126
+ orphanedServerIds.add(id);
127
+ }
128
+ }
129
+ if (b.type === "web_search_tool_result") {
130
+ const id = (b as { tool_use_id: string }).tool_use_id;
131
+ if (!serverToolPairing.resolvedPairIds.has(id)) {
132
+ orphanedWebSearchResultIds.add(id);
133
+ }
133
134
  }
134
135
  }
135
136
 
@@ -20,18 +20,27 @@ export interface StripResult {
20
20
  /**
21
21
  * Replaces every `web_search_tool_result` block in the message list with a
22
22
  * plain `text` summary of its results, and drops the paired `server_tool_use`
23
- * that produced it.
23
+ * that produced it, wherever that use lives. The pair usually shares one
24
+ * assistant message, but a deferred execution (the API runs the search on the
25
+ * request after a mixed server/client parallel tool group) places the result
26
+ * at the head of the following assistant message, so pairing is resolved
27
+ * across the whole list. Dropping both sides together keeps the history free
28
+ * of unpaired server-tool blocks, which the provider layer would otherwise
29
+ * repair with a synthetic error result.
30
+ *
31
+ * A `server_tool_use` with no result anywhere in the list is left untouched:
32
+ * either the search is still pending (deferred tail, executed by the provider
33
+ * on this request) or it is a genuine orphan the provider layer repairs.
24
34
  *
25
35
  * Anthropic's `encrypted_content` tokens attached to each `web_search_result`
26
36
  * are opaque server tokens with bounded validity (they expire and/or are
27
37
  * route-scoped). Replaying a stale token produces
28
38
  * `messages.N.content.M: Invalid encrypted_content in search_result block`.
29
39
  * For historical turns the model does not need the opaque token to re-read
30
- * the body — a title+url summary is sufficient to preserve context.
40
+ * the body: a title+url summary is sufficient to preserve context.
31
41
  *
32
- * Intended to run on `runMessages` immediately before the agent loop starts a
33
- * new turn, at which point every `web_search_tool_result` in the list is by
34
- * definition from a prior turn.
42
+ * Runs on the outbound message list before each model call; every executed
43
+ * search in the list is summarized regardless of which turn produced it.
35
44
  */
36
45
  export function stripHistoricalWebSearchResults(
37
46
  messages: Message[],
@@ -42,42 +51,45 @@ export function stripHistoricalWebSearchResults(
42
51
  messagesModified: 0,
43
52
  };
44
53
 
45
- const next: Message[] = messages.map((msg) => {
46
- const droppedServerToolUseIds = new Set<string>();
47
- const transformed: ContentBlock[] = [];
48
-
54
+ const strippedToolUseIds = new Set<string>();
55
+ for (const msg of messages) {
49
56
  for (const block of msg.content) {
50
- if (block.type !== "web_search_tool_result") {
51
- continue;
57
+ if (block.type === "web_search_tool_result") {
58
+ strippedToolUseIds.add(
59
+ (block as WebSearchToolResultContent).tool_use_id,
60
+ );
52
61
  }
53
- const wsr = block as WebSearchToolResultContent;
54
- const query = findQueryForToolUseId(msg.content, wsr.tool_use_id);
55
- transformed.push(formatAsText(wsr, query));
56
- droppedServerToolUseIds.add(wsr.tool_use_id);
57
- stats.blocksStripped++;
58
- }
59
-
60
- if (droppedServerToolUseIds.size === 0) {
61
- return msg;
62
62
  }
63
+ }
63
64
 
65
+ const next: Message[] = messages.map((msg) => {
66
+ let modified = false;
64
67
  const rewritten: ContentBlock[] = [];
65
- let wsrIndex = 0;
68
+
66
69
  for (const block of msg.content) {
67
70
  if (block.type === "server_tool_use") {
68
71
  const stu = block as ServerToolUseContent;
69
- if (droppedServerToolUseIds.has(stu.id)) {
72
+ if (strippedToolUseIds.has(stu.id)) {
70
73
  stats.serverToolUsesDropped++;
74
+ modified = true;
71
75
  continue;
72
76
  }
73
77
  rewritten.push(block);
74
78
  } else if (block.type === "web_search_tool_result") {
75
- rewritten.push(transformed[wsrIndex++]);
79
+ const wsr = block as WebSearchToolResultContent;
80
+ rewritten.push(
81
+ formatAsText(wsr, findQueryForToolUseId(messages, wsr.tool_use_id)),
82
+ );
83
+ stats.blocksStripped++;
84
+ modified = true;
76
85
  } else {
77
86
  rewritten.push(block);
78
87
  }
79
88
  }
80
89
 
90
+ if (!modified) {
91
+ return msg;
92
+ }
81
93
  stats.messagesModified++;
82
94
  return { ...msg, content: rewritten };
83
95
  });
@@ -86,19 +98,21 @@ export function stripHistoricalWebSearchResults(
86
98
  }
87
99
 
88
100
  function findQueryForToolUseId(
89
- blocks: ContentBlock[],
101
+ messages: Message[],
90
102
  toolUseId: string,
91
103
  ): string | null {
92
- for (const b of blocks) {
93
- if (b.type !== "server_tool_use") {
94
- continue;
95
- }
96
- const stu = b as ServerToolUseContent;
97
- if (stu.id !== toolUseId) {
98
- continue;
104
+ for (const msg of messages) {
105
+ for (const b of msg.content) {
106
+ if (b.type !== "server_tool_use") {
107
+ continue;
108
+ }
109
+ const stu = b as ServerToolUseContent;
110
+ if (stu.id !== toolUseId) {
111
+ continue;
112
+ }
113
+ const q = stu.input?.query;
114
+ return typeof q === "string" ? q : null;
99
115
  }
100
- const q = stu.input?.query;
101
- return typeof q === "string" ? q : null;
102
116
  }
103
117
  return null;
104
118
  }
@@ -18,6 +18,12 @@ import {
18
18
  PLACEHOLDER_EMPTY_TURN,
19
19
  } from "../placeholder-sentinels.js";
20
20
  import { recordProviderRequestDiagnostics } from "../request-diagnostics.js";
21
+ import {
22
+ analyzeServerToolPairing,
23
+ isServerToolUseBlock,
24
+ isWebSearchToolResultBlock,
25
+ type ServerToolPairing,
26
+ } from "../server-tool-pairing.js";
21
27
  import { createStreamTimeout } from "../stream-timeout.js";
22
28
  import type {
23
29
  ContentBlock,
@@ -489,73 +495,54 @@ function normalizeFollowingUserContent(
489
495
  };
490
496
  }
491
497
 
492
- /** Type-guard for server_tool_use blocks. */
493
- function isServerToolUseBlock(
494
- block: unknown,
495
- ): block is { type: "server_tool_use"; id: string; name: string } {
496
- return (
497
- typeof block === "object" &&
498
- block != null &&
499
- (block as { type: string }).type === "server_tool_use"
500
- );
501
- }
502
-
503
- /** Type-guard for web_search_tool_result blocks. */
504
- function isWebSearchToolResultBlock(block: unknown): block is {
505
- type: "web_search_tool_result";
506
- tool_use_id: string;
507
- content: unknown;
508
- } {
509
- return (
510
- typeof block === "object" &&
511
- block != null &&
512
- (block as { type: string }).type === "web_search_tool_result"
513
- );
514
- }
515
-
516
498
  /**
517
- * Repair orphaned server-side tool blocks within assistant messages. Server-
518
- * side tools (e.g. web_search) are self-paired: the assistant message should
519
- * contain both server_tool_use and its matching web_search_tool_result. Either
520
- * side can go missing — a partial stream may drop the result, or a downstream
521
- * step (history reload, message split, compaction) may drop the use block.
522
- * Both cases trigger an Anthropic 400 on the next request, so this function
523
- * handles both directions:
499
+ * Repair orphaned server-side tool blocks within assistant messages, using the
500
+ * cross-message pairing from {@link analyzeServerToolPairing}. A pair can go
501
+ * missing on either side: a partial stream may drop the result, or a
502
+ * downstream step (history reload, message split, compaction) may drop the use
503
+ * block. Both cases trigger an Anthropic 400 on the next request, so this
504
+ * function handles both directions:
524
505
  *
525
- * - server_tool_use without paired result: inject a synthetic error result.
526
- * - web_search_tool_result without paired server_tool_use: downgrade to a
506
+ * - server_tool_use without a paired result: inject a synthetic error
507
+ * result.
508
+ * - web_search_tool_result without a paired server_tool_use: downgrade to a
527
509
  * text block describing what was found so the model retains context.
510
+ *
511
+ * Deferred use blocks (see {@link ServerToolPairing.deferredUseIds}) are not
512
+ * orphans: they are left intact so the provider executes the search on this
513
+ * request instead of seeing a synthetic failure.
528
514
  */
529
515
  function repairOrphanedServerToolBlocks(
530
516
  messages: Anthropic.MessageParam[],
517
+ pairing: ServerToolPairing,
531
518
  ): Anthropic.MessageParam[] {
519
+ if (pairing.deferredUseIds.size > 0) {
520
+ log.info(
521
+ { deferredIds: Array.from(pairing.deferredUseIds) },
522
+ "Preserving deferred server_tool_use blocks for provider-side execution",
523
+ );
524
+ }
532
525
  return messages.map((msg) => {
533
526
  if (msg.role !== "assistant") {
534
527
  return msg;
535
528
  }
536
529
  const content = Array.isArray(msg.content) ? msg.content : [];
537
530
 
538
- const serverToolUseIds = new Set<string>();
539
- const webSearchResultIds = new Set<string>();
540
- for (const block of content) {
541
- if (isServerToolUseBlock(block)) {
542
- serverToolUseIds.add(block.id);
543
- }
544
- if (isWebSearchToolResultBlock(block)) {
545
- webSearchResultIds.add(block.tool_use_id);
546
- }
547
- }
548
-
549
531
  const orphanServerToolUseIds = new Set<string>();
550
- for (const id of serverToolUseIds) {
551
- if (!webSearchResultIds.has(id)) {
552
- orphanServerToolUseIds.add(id);
553
- }
554
- }
555
532
  const orphanWebSearchResultIds = new Set<string>();
556
- for (const id of webSearchResultIds) {
557
- if (!serverToolUseIds.has(id)) {
558
- orphanWebSearchResultIds.add(id);
533
+ for (const block of content) {
534
+ if (
535
+ isServerToolUseBlock(block) &&
536
+ !pairing.resolvedPairIds.has(block.id) &&
537
+ !pairing.deferredUseIds.has(block.id)
538
+ ) {
539
+ orphanServerToolUseIds.add(block.id);
540
+ }
541
+ if (
542
+ isWebSearchToolResultBlock(block) &&
543
+ !pairing.resolvedPairIds.has(block.tool_use_id)
544
+ ) {
545
+ orphanWebSearchResultIds.add(block.tool_use_id);
559
546
  }
560
547
  }
561
548
 
@@ -709,9 +696,18 @@ function findActiveToolUseContinuationStart(
709
696
 
710
697
  function ensureToolPairing(
711
698
  messages: Anthropic.MessageParam[],
699
+ pairing: ServerToolPairing,
712
700
  ): Anthropic.MessageParam[] {
713
701
  const result: Anthropic.MessageParam[] = [];
714
702
 
703
+ // Messages carrying a deferred or cross-message-paired server tool block
704
+ // must go out verbatim: the provider pairs and executes these blocks across
705
+ // messages itself, and reordering or splitting them breaks that pairing.
706
+ const verbatimIds = new Set([
707
+ ...pairing.deferredUseIds,
708
+ ...pairing.crossMessageIds,
709
+ ]);
710
+
715
711
  let i = 0;
716
712
  while (i < messages.length) {
717
713
  const msg = messages[i];
@@ -724,8 +720,23 @@ function ensureToolPairing(
724
720
 
725
721
  const content = Array.isArray(msg.content) ? msg.content : [];
726
722
 
723
+ const hasVerbatimServerBlock =
724
+ verbatimIds.size > 0 &&
725
+ content.some(
726
+ (block) =>
727
+ (isServerToolUseBlock(block) && verbatimIds.has(block.id)) ||
728
+ (isWebSearchToolResultBlock(block) &&
729
+ verbatimIds.has(block.tool_use_id)),
730
+ );
731
+
727
732
  const { pairedContent, carryoverContent, toolUseIds } =
728
- splitAssistantForToolPairing(content);
733
+ hasVerbatimServerBlock
734
+ ? {
735
+ pairedContent: content,
736
+ carryoverContent: [] as Anthropic.ContentBlockParam[],
737
+ toolUseIds: getOrderedToolUseIds(content),
738
+ }
739
+ : splitAssistantForToolPairing(content);
729
740
 
730
741
  if (toolUseIds.length === 0) {
731
742
  result.push(msg);
@@ -2016,7 +2027,11 @@ export class AnthropicProvider implements Provider {
2016
2027
  formatted[i] = { ...msg, content: stripped };
2017
2028
  }
2018
2029
 
2019
- return ensureToolPairing(repairOrphanedServerToolBlocks(formatted));
2030
+ const serverToolPairing = analyzeServerToolPairing(formatted);
2031
+ return ensureToolPairing(
2032
+ repairOrphanedServerToolBlocks(formatted, serverToolPairing),
2033
+ serverToolPairing,
2034
+ );
2020
2035
  }
2021
2036
 
2022
2037
  /**
@@ -0,0 +1,134 @@
1
+ // Single source of truth for pairing server-side tool blocks (e.g. native
2
+ // web_search) across a message list. Pure leaf module: consumed by the
3
+ // provider formatter, load-time history repair, and any other pass that must
4
+ // distinguish a genuine orphan from a pair the provider manages itself.
5
+
6
+ /**
7
+ * Minimal structural message shape shared by daemon `Message[]` and
8
+ * `Anthropic.MessageParam[]`.
9
+ */
10
+ export interface ServerToolPairingMessage {
11
+ role: string;
12
+ content: string | ReadonlyArray<unknown>;
13
+ }
14
+
15
+ /** Type-guard for server_tool_use blocks. */
16
+ export function isServerToolUseBlock(
17
+ block: unknown,
18
+ ): block is { type: "server_tool_use"; id: string; name: string } {
19
+ return (
20
+ typeof block === "object" &&
21
+ block != null &&
22
+ (block as { type: string }).type === "server_tool_use"
23
+ );
24
+ }
25
+
26
+ /** Type-guard for web_search_tool_result blocks. */
27
+ export function isWebSearchToolResultBlock(block: unknown): block is {
28
+ type: "web_search_tool_result";
29
+ tool_use_id: string;
30
+ content: unknown;
31
+ } {
32
+ return (
33
+ typeof block === "object" &&
34
+ block != null &&
35
+ (block as { type: string }).type === "web_search_tool_result"
36
+ );
37
+ }
38
+
39
+ function isToolResultShapedBlock(block: unknown): boolean {
40
+ return (
41
+ typeof block === "object" &&
42
+ block != null &&
43
+ (block as { type?: string }).type === "tool_result"
44
+ );
45
+ }
46
+
47
+ function contentBlocks(msg: ServerToolPairingMessage): ReadonlyArray<unknown> {
48
+ return Array.isArray(msg.content) ? msg.content : [];
49
+ }
50
+
51
+ /**
52
+ * Pairing analysis for server-side tool blocks across the whole message list.
53
+ *
54
+ * Server-side tools are provider-executed, and the pair can legitimately span
55
+ * messages: when the model requests a server tool and a client tool in the
56
+ * same parallel tool-call group, the API defers the search (stop_reason
57
+ * `tool_use`, no result emitted) and executes it on the next request, placing
58
+ * the web_search_tool_result at the head of the next assistant message. Both
59
+ * the deferred tail and the resulting split pair must be sent back verbatim
60
+ * for the provider to pair and execute them.
61
+ */
62
+ export interface ServerToolPairing {
63
+ /**
64
+ * Ids with a complete use/result pair: the server_tool_use appears in the
65
+ * same message as its web_search_tool_result or in an earlier one.
66
+ */
67
+ resolvedPairIds: Set<string>;
68
+ /**
69
+ * Unanswered use ids in the final assistant message of an active tool-loop
70
+ * continuation (only user messages after it, carrying tool_result blocks or
71
+ * nothing at all). The provider executes these on this request.
72
+ */
73
+ deferredUseIds: Set<string>;
74
+ /** Ids whose use/result pair spans two messages. */
75
+ crossMessageIds: Set<string>;
76
+ }
77
+
78
+ export function analyzeServerToolPairing(
79
+ messages: ReadonlyArray<ServerToolPairingMessage>,
80
+ ): ServerToolPairing {
81
+ const useIndexById = new Map<string, number>();
82
+ const resultIndexById = new Map<string, number>();
83
+ let lastAssistantIndex = -1;
84
+
85
+ messages.forEach((msg, index) => {
86
+ if (msg.role !== "assistant") {
87
+ // Server-tool blocks legitimately live only in assistant messages; a
88
+ // stray result in a user message is a repair concern, not a pair.
89
+ return;
90
+ }
91
+ lastAssistantIndex = index;
92
+ for (const block of contentBlocks(msg)) {
93
+ if (isServerToolUseBlock(block) && !useIndexById.has(block.id)) {
94
+ useIndexById.set(block.id, index);
95
+ }
96
+ if (
97
+ isWebSearchToolResultBlock(block) &&
98
+ !resultIndexById.has(block.tool_use_id)
99
+ ) {
100
+ resultIndexById.set(block.tool_use_id, index);
101
+ }
102
+ }
103
+ });
104
+
105
+ const resolvedPairIds = new Set<string>();
106
+ const crossMessageIds = new Set<string>();
107
+ useIndexById.forEach((useIndex, id) => {
108
+ const resultIndex = resultIndexById.get(id);
109
+ if (resultIndex !== undefined && resultIndex >= useIndex) {
110
+ resolvedPairIds.add(id);
111
+ if (resultIndex > useIndex) {
112
+ crossMessageIds.add(id);
113
+ }
114
+ }
115
+ });
116
+
117
+ const deferredUseIds = new Set<string>();
118
+ if (lastAssistantIndex >= 0) {
119
+ const trailing = messages.slice(lastAssistantIndex + 1);
120
+ const trailingAllUser = trailing.every((m) => m.role === "user");
121
+ const trailingHasToolResult = trailing.some((m) =>
122
+ contentBlocks(m).some(isToolResultShapedBlock),
123
+ );
124
+ if (trailingAllUser && (trailing.length === 0 || trailingHasToolResult)) {
125
+ for (const block of contentBlocks(messages[lastAssistantIndex])) {
126
+ if (isServerToolUseBlock(block) && !resolvedPairIds.has(block.id)) {
127
+ deferredUseIds.add(block.id);
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ return { resolvedPairIds, deferredUseIds, crossMessageIds };
134
+ }