@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 +1 -1
- package/src/__tests__/anthropic-provider.test.ts +136 -19
- package/src/__tests__/history-repair.test.ts +112 -12
- package/src/__tests__/web-search-history.test.ts +96 -0
- package/src/agent/history-repair/history-repair.ts +27 -26
- package/src/daemon/web-search-history.ts +47 -33
- package/src/providers/anthropic/client.ts +69 -54
- package/src/providers/server-tool-pairing.ts +134 -0
package/package.json
CHANGED
|
@@ -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("
|
|
1414
|
-
//
|
|
1415
|
-
//
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1601
|
-
|
|
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
|
|
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
|
-
//
|
|
1616
|
-
const
|
|
1617
|
-
expect(
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
|
576
|
-
//
|
|
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(
|
|
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(
|
|
599
|
-
expect(repaired[1].content[
|
|
600
|
-
type: "
|
|
601
|
-
|
|
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
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
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
|
|
110
|
-
//
|
|
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
|
|
131
|
-
if (
|
|
132
|
-
|
|
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
|
|
40
|
+
* the body: a title+url summary is sufficient to preserve context.
|
|
31
41
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
46
|
-
|
|
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
|
|
51
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
101
|
+
messages: Message[],
|
|
90
102
|
toolUseId: string,
|
|
91
103
|
): string | null {
|
|
92
|
-
for (const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
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
|
|
526
|
-
*
|
|
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
|
|
557
|
-
if (
|
|
558
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|