@juspay/neurolink 11.11.0 → 11.11.2

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.
@@ -21,8 +21,9 @@ import { estimateTokens } from "../../utils/tokenEstimation.js";
21
21
  import { redactUrlCredentials } from "../../utils/logSanitize.js";
22
22
  import { ANTHROPIC_MAX_CACHE_BREAKPOINTS, applyAnthropicHistoryCacheBreakpoints, countAnthropicCacheMarkers, } from "../../utils/anthropicCacheBreakpoints.js";
23
23
  import { calculateCost } from "../../utils/pricing.js";
24
- import { resolveDeferredTool } from "../../tools/toolDiscovery.js";
25
24
  import { stringifyAnthropicToolOutput } from "./toolOutput.js";
25
+ import { createAnthropicLoopAdapter } from "./loopAdapter.js";
26
+ import { runAgenticLoop } from "../../core/loopEngine.js";
26
27
  import { createAnthropicConfig, getProviderModel, validateApiKey, } from "../../utils/providerConfig.js";
27
28
  import { composeAbortSignals, createTimeoutController, mergeAbortSignals, } from "../../utils/timeout.js";
28
29
  import { resolveToolChoice } from "../../utils/toolChoice.js";
@@ -31,7 +32,6 @@ import { NoOutputGeneratedError } from "../../utils/generationErrors.js";
31
32
  import { buildNoOutputSentinel, stampNoOutputSpan, } from "../../utils/noOutputSentinel.js";
32
33
  import { convertZodToJsonSchema } from "../../utils/schemaConversion.js";
33
34
  import { resolveClaudeMaxTokens } from "../../utils/tokenLimits.js";
34
- import { withProviderRetry } from "../../utils/providerRetry.js";
35
35
  import { toAnthropicImageBlock, fileToAnthropicBlock, } from "../anthropicImageBlocks.js";
36
36
  import { resolveSamplingParams } from "../../models/modelRegistry.js";
37
37
  import { createDeferredAnalytics, stringifyToolInput, } from "../openaiChatCompletionsClient.js";
@@ -39,7 +39,7 @@ import { createStreamChannel } from "../../core/streamChannel.js";
39
39
  import { toNativeToolDeclarations } from "../../core/nativeToolFormat.js";
40
40
  import { ANTHROPIC_BETA_HEADERS } from "./constants.js";
41
41
  import { cacheControlOf } from "./cacheControl.js";
42
- import { appendFinalResultInstruction, appendFinalResultTool, FINAL_RESULT_TOOL_NAME, stringifyFinalResultInput, } from "./structuredOutput.js";
42
+ import { appendFinalResultInstruction, appendFinalResultTool, FINAL_RESULT_TOOL_NAME, } from "./structuredOutput.js";
43
43
  // AnthropicProviderConfig is imported from types/providers.ts
44
44
  // Re-export for backward compatibility
45
45
  // Configuration helpers - now using consolidated utility
@@ -1495,32 +1495,21 @@ export class AnthropicProvider extends BaseProvider {
1495
1495
  estimateTokens(text(tools), "anthropic"));
1496
1496
  };
1497
1497
  const runLoop = async () => {
1498
- const conversation = payload.messages.slice();
1499
1498
  // The provider's REAL prompt-token count for the previous step,
1500
1499
  // calibrating the guard's char-based estimate for free, paired with the
1501
1500
  // guard's own estimate for that same request — a ratio between counts of
1502
1501
  // two different payloads would be meaningless.
1503
1502
  let lastObservedPromptTokens;
1504
1503
  let lastSentEstimate;
1505
- for (let step = 0; step < maxSteps; step++) {
1506
- // Mid-turn discovery sync: search_tools (tools.discovery) hydrates
1507
- // new tools into toolsRecord between steps; Claude only calls tools
1508
- // declared in the request, so advertise them now (`params` below
1509
- // rebuilds from anthropicTools every step).
1510
- if (anthropicTools) {
1511
- const declared = new Set(anthropicTools.map((t) => t.name));
1512
- const hydrated = Object.fromEntries(Object.entries(toolsRecord).filter(([name]) => !declared.has(name)));
1513
- if (Object.keys(hydrated).length > 0) {
1514
- anthropicTools.push(...(toNativeToolDeclarations(hydrated, "input_schema") ?? []));
1515
- logger.info(`[Anthropic] ${Object.keys(hydrated).length} tool(s) hydrated mid-turn via discovery: ${Object.keys(hydrated).join(", ")}`);
1516
- }
1517
- }
1518
- // In-turn context guard. This loop appends an assistant tool_use
1519
- // message plus a user tool_result message every step — growth the
1520
- // pre-dispatch budget check never sees. Without it a long agentic run
1521
- // overflows the window mid-loop and loses every completed step.
1522
- // Returns undefined while the request still fits, leaving the history
1523
- // byte-identical so the rolling cache prefix below stays valid.
1504
+ /**
1505
+ * Reclaim, as a PURE function of the conversation.
1506
+ *
1507
+ * The hand-rolled loop this replaces rebuilt in place
1508
+ * (`conversation.length = 0; conversation.push(...rebuilt)`) because it
1509
+ * owned the array. The engine owns it now and assigns what this
1510
+ * returns, so mutating here would corrupt a retried or reclaimed step.
1511
+ */
1512
+ const planReclaim = (conversation) => {
1524
1513
  const reclaim = planAnthropicLoopReclaim({
1525
1514
  conversation,
1526
1515
  availableInputTokens: getAvailableInputTokens("anthropic", modelId, options.maxTokens ?? undefined),
@@ -1535,69 +1524,78 @@ export class AnthropicProvider extends BaseProvider {
1535
1524
  lastSentEstimate = tokens;
1536
1525
  },
1537
1526
  });
1538
- if (reclaim) {
1539
- // Applied HERE, in the loop's own concrete types: the guard decides,
1540
- // the caller mutates. Dropping an assistant tool_use message together
1541
- // with its user tool_result message is what keeps blocks paired.
1542
- const dropSet = new Set(reclaim.drop);
1543
- const truncateSet = new Set(reclaim.truncate);
1544
- const rebuilt = [];
1545
- for (let i = 0; i < conversation.length; i++) {
1546
- if (dropSet.has(i)) {
1547
- continue;
1548
- }
1549
- const message = conversation[i];
1550
- if (truncateSet.has(i) && Array.isArray(message.content)) {
1551
- rebuilt.push({
1552
- ...message,
1553
- content: message.content.map((block) => block.type === "tool_result"
1554
- ? {
1555
- ...block,
1556
- content: previewAnthropicToolResultText(typeof block.content === "string"
1557
- ? block.content
1558
- : (JSON.stringify(block.content) ?? "")),
1559
- }
1560
- : block),
1561
- });
1562
- continue;
1563
- }
1564
- rebuilt.push(message);
1527
+ if (!reclaim) {
1528
+ return undefined;
1529
+ }
1530
+ // Dropping an assistant tool_use message together with its user
1531
+ // tool_result message is what keeps blocks paired.
1532
+ const dropSet = new Set(reclaim.drop);
1533
+ const truncateSet = new Set(reclaim.truncate);
1534
+ const rebuilt = [];
1535
+ for (let i = 0; i < conversation.length; i++) {
1536
+ if (dropSet.has(i)) {
1537
+ continue;
1565
1538
  }
1566
- if (dropSet.size > 0) {
1567
- // Anthropic requires user/assistant alternation around tool blocks;
1568
- // the note is a user turn placed immediately before the first
1569
- // surviving assistant tool_use turn, which preserves it.
1570
- let noteIndex = rebuilt.findIndex((m) => Array.isArray(m.content) &&
1571
- m.content.some((b) => b.type === "tool_use" || b.type === "tool_result"));
1572
- if (noteIndex < 0) {
1573
- noteIndex = Math.min(1, rebuilt.length);
1574
- }
1575
- rebuilt.splice(noteIndex, 0, {
1576
- role: "user",
1577
- content: [{ type: "text", text: ANTHROPIC_ELISION_NOTE }],
1539
+ const message = conversation[i];
1540
+ if (truncateSet.has(i) && Array.isArray(message.content)) {
1541
+ rebuilt.push({
1542
+ ...message,
1543
+ content: message.content.map((block) => block.type === "tool_result"
1544
+ ? {
1545
+ ...block,
1546
+ content: previewAnthropicToolResultText(typeof block.content === "string"
1547
+ ? block.content
1548
+ : (JSON.stringify(block.content) ?? "")),
1549
+ }
1550
+ : block),
1578
1551
  });
1552
+ continue;
1553
+ }
1554
+ rebuilt.push(message);
1555
+ }
1556
+ if (dropSet.size > 0) {
1557
+ // Anthropic requires user/assistant alternation around tool blocks;
1558
+ // the note is a user turn placed immediately before the first
1559
+ // surviving assistant tool_use turn, which preserves it.
1560
+ let noteIndex = rebuilt.findIndex((m) => Array.isArray(m.content) &&
1561
+ m.content.some((b) => b.type === "tool_use" || b.type === "tool_result"));
1562
+ if (noteIndex < 0) {
1563
+ noteIndex = Math.min(1, rebuilt.length);
1564
+ }
1565
+ rebuilt.splice(noteIndex, 0, {
1566
+ role: "user",
1567
+ content: [{ type: "text", text: ANTHROPIC_ELISION_NOTE }],
1568
+ });
1569
+ }
1570
+ return rebuilt;
1571
+ };
1572
+ const buildParams = (conversation) => {
1573
+ // Mid-turn discovery sync: search_tools (tools.discovery) hydrates
1574
+ // new tools into toolsRecord between steps; Claude only calls tools
1575
+ // declared in the request, so advertise them now.
1576
+ if (anthropicTools) {
1577
+ const declared = new Set(anthropicTools.map((t) => t.name));
1578
+ const hydrated = Object.fromEntries(Object.entries(toolsRecord).filter(([name]) => !declared.has(name)));
1579
+ if (Object.keys(hydrated).length > 0) {
1580
+ const extra = toNativeToolDeclarations(hydrated, "input_schema");
1581
+ if (extra && extra.length > 0) {
1582
+ anthropicTools = [...anthropicTools, ...extra];
1583
+ }
1579
1584
  }
1580
- conversation.length = 0;
1581
- conversation.push(...rebuilt);
1582
1585
  }
1583
1586
  // Prompt-cache parity with the native Vertex+Claude path — rolling
1584
- // history breakpoints, re-applied per step so the stable prefix
1585
- // stays byte-identical while the breakpoint follows the growing
1586
- // tail. Budget respects markers upstream layers already placed
1587
- // (system / last tool / message blocks) so the request never
1588
- // exceeds Anthropic's four-marker cap. Pure: `conversation` itself
1589
- // is never mutated, so re-counting per step stays stable.
1587
+ // history breakpoints, re-applied per step so the stable prefix stays
1588
+ // byte-identical while the breakpoint follows the growing tail.
1590
1589
  const cacheMarkersUsed = countAnthropicCacheMarkers({
1591
1590
  system: payload.system,
1592
1591
  tools: anthropicTools,
1593
1592
  messages: conversation,
1594
1593
  });
1595
1594
  const cachedConversation = applyAnthropicHistoryCacheBreakpoints(conversation, ANTHROPIC_MAX_CACHE_BREAKPOINTS - cacheMarkersUsed);
1596
- // Registry-driven strip (Sonnet 5 / Opus 4.7+ / Fable 5 families)
1597
1595
  const streamSamplingParams = resolveSamplingParams("anthropic", modelId, options.temperature !== undefined && options.temperature !== null
1598
1596
  ? { temperature: options.temperature }
1599
1597
  : {}, "anthropic.executeStream");
1600
- const params = {
1598
+ return {
1601
1599
  model: modelId,
1602
1600
  messages: cachedConversation,
1603
1601
  max_tokens: resolveClaudeMaxTokens(modelId, options.maxTokens),
@@ -1612,239 +1610,123 @@ export class AnthropicProvider extends BaseProvider {
1612
1610
  ...(anthropicToolChoice ? { tool_choice: anthropicToolChoice } : {}),
1613
1611
  ...(thinking ? { thinking } : {}),
1614
1612
  };
1615
- const events = await withProviderRetry(() => client.messages.create(params, {
1616
- signal: abortSignal ?? undefined,
1617
- }), trace.getActiveSpan() ?? undefined, `${this.providerName} stream`);
1618
- // Per-step accumulators, keyed by content-block index so blocks are
1619
- // replayed to the conversation in order (thinking blocks must be
1620
- // passed back with their signatures when tool use continues a turn).
1621
- const blockTypes = new Map();
1622
- const textAcc = new Map();
1623
- const thinkingAcc = new Map();
1624
- const toolAcc = new Map();
1625
- let stopReason = null;
1626
- // message_start carries a small output placeholder and message_delta
1627
- // reports the CUMULATIVE output for the message — latest wins within
1628
- // the step (adding both double-counted the placeholder every step).
1629
- // Write-through: each event folds only the DELTA over this step's
1630
- // previous value into totalOutput, so the total is correct at every
1631
- // point mid-drain — a step killed mid-stream (abort/timeout) still
1632
- // counts the billed output it already reported.
1633
- let stepOutputTokens = 0;
1634
- for await (const event of events) {
1635
- if (event.type === "message_start") {
1636
- totalInput += event.message.usage.input_tokens ?? 0;
1637
- const startOutputTokens = event.message.usage.output_tokens ?? 0;
1638
- totalOutput += startOutputTokens - stepOutputTokens;
1639
- stepOutputTokens = startOutputTokens;
1640
- // Anthropic reports cache reads/writes SEPARATELY from
1641
- // input_tokens on the same message_start event — without these
1642
- // the streaming path silently drops all cache accounting.
1643
- totalCacheRead += event.message.usage.cache_read_input_tokens ?? 0;
1644
- totalCacheWrite +=
1645
- event.message.usage.cache_creation_input_tokens ?? 0;
1646
- // Calibration signal for the in-turn guard: the FULL prompt size,
1647
- // which on this path means uncached input plus both cache tiers.
1648
- // Using input_tokens alone would read a cache-hit step as tiny and
1649
- // let the guard drift far under the real cost.
1650
- lastObservedPromptTokens =
1651
- (event.message.usage.input_tokens ?? 0) +
1652
- (event.message.usage.cache_read_input_tokens ?? 0) +
1653
- (event.message.usage.cache_creation_input_tokens ?? 0);
1654
- }
1655
- else if (event.type === "content_block_start") {
1656
- blockTypes.set(event.index, event.content_block.type);
1657
- if (event.content_block.type === "tool_use") {
1658
- toolAcc.set(event.index, {
1659
- id: event.content_block.id,
1660
- name: event.content_block.name,
1661
- inputJson: "",
1662
- });
1663
- }
1664
- }
1665
- else if (event.type === "content_block_delta") {
1666
- const delta = event.delta;
1667
- if (delta.type === "text_delta") {
1668
- textAcc.set(event.index, (textAcc.get(event.index) ?? "") + delta.text);
1669
- if (finalResultActive) {
1670
- bufferedText += delta.text;
1671
- }
1672
- else {
1673
- pushChunk({ content: delta.text });
1674
- }
1675
- }
1676
- else if (delta.type === "thinking_delta") {
1677
- const acc = thinkingAcc.get(event.index) ?? {
1678
- text: "",
1679
- signature: "",
1680
- };
1681
- acc.text += delta.thinking;
1682
- thinkingAcc.set(event.index, acc);
1683
- // Reasoning rides the dedicated chunk channel; `content` stays
1684
- // an always-present string so plain-text consumers are safe.
1685
- pushChunk({ content: "", reasoning: delta.thinking });
1686
- }
1687
- else if (delta.type === "signature_delta") {
1688
- const acc = thinkingAcc.get(event.index) ?? {
1689
- text: "",
1690
- signature: "",
1691
- };
1692
- acc.signature += delta.signature;
1693
- thinkingAcc.set(event.index, acc);
1694
- }
1695
- else if (delta.type === "input_json_delta") {
1696
- const acc = toolAcc.get(event.index);
1697
- if (acc) {
1698
- acc.inputJson += delta.partial_json;
1699
- }
1700
- }
1701
- }
1702
- else if (event.type === "message_delta") {
1703
- stopReason = event.delta.stop_reason ?? stopReason;
1704
- const cumulativeOutputTokens = event.usage?.output_tokens ?? stepOutputTokens;
1705
- totalOutput += cumulativeOutputTokens - stepOutputTokens;
1706
- stepOutputTokens = cumulativeOutputTokens;
1707
- }
1708
- }
1709
- lastStop = stopReason;
1710
- // final_result is terminal: its arguments ARE the answer, so the turn
1711
- // ends here and any tool calls issued alongside it are not executed
1712
- // (parity with the native Vertex loops). It is never executed as a
1713
- // tool, never recorded in toolsUsed, and never stored as a tool
1714
- // execution — the pattern stays invisible to callers.
1715
- if (finalResultActive) {
1716
- const finalCall = [...toolAcc.values()].find((acc) => acc.name === FINAL_RESULT_TOOL_NAME);
1717
- if (finalCall) {
1718
- finalResultText = stringifyFinalResultInput(finalCall.inputJson);
1719
- lastStop = "end_turn";
1720
- logger.debug("[Anthropic] Extracted structured output from final_result tool (stream)", { chars: finalResultText.length });
1721
- break;
1722
- }
1723
- }
1724
- if (stopReason !== "tool_use" || toolAcc.size === 0) {
1725
- break;
1726
- }
1727
- // Replay this assistant turn (thinking + text + tool_use blocks, in
1728
- // block order) then execute the requested tools and append their
1729
- // results as a user turn — the native multi-step tool loop.
1730
- const assistantBlocks = [];
1731
- const orderedIndexes = [...blockTypes.keys()].sort((a, b) => a - b);
1732
- for (const idx of orderedIndexes) {
1733
- const type = blockTypes.get(idx);
1734
- if (type === "thinking") {
1735
- const acc = thinkingAcc.get(idx);
1736
- if (acc && acc.text.length > 0) {
1737
- assistantBlocks.push({
1738
- type: "thinking",
1739
- thinking: acc.text,
1740
- signature: acc.signature,
1741
- });
1742
- }
1743
- }
1744
- else if (type === "text") {
1745
- const text = textAcc.get(idx);
1746
- if (text && text.length > 0) {
1747
- assistantBlocks.push({ type: "text", text });
1748
- }
1749
- }
1750
- else if (type === "tool_use") {
1751
- const acc = toolAcc.get(idx);
1752
- if (acc) {
1753
- let input;
1754
- try {
1755
- input = acc.inputJson ? JSON.parse(acc.inputJson) : {};
1756
- }
1757
- catch {
1758
- input = {};
1759
- }
1760
- assistantBlocks.push({
1761
- type: "tool_use",
1762
- id: acc.id,
1763
- name: acc.name,
1764
- input,
1765
- });
1766
- }
1767
- }
1768
- }
1769
- conversation.push({ role: "assistant", content: assistantBlocks });
1770
- const resultBlocks = [];
1771
- const toolCallsForStorage = [];
1772
- const toolResultsForStorage = [];
1773
- for (const acc of toolAcc.values()) {
1774
- let args;
1775
- try {
1776
- args = acc.inputJson
1777
- ? JSON.parse(acc.inputJson)
1778
- : {};
1613
+ };
1614
+ const baseAdapter = createAnthropicLoopAdapter({
1615
+ client,
1616
+ maxSteps,
1617
+ toolsRecord,
1618
+ buildParams,
1619
+ planReclaim,
1620
+ noteObservedPromptTokens: (tokens) => {
1621
+ lastObservedPromptTokens = tokens;
1622
+ },
1623
+ ...(finalResultActive
1624
+ ? {
1625
+ finalResultToolName: FINAL_RESULT_TOOL_NAME,
1626
+ onTerminalResult: (text) => {
1627
+ finalResultText = text;
1628
+ logger.debug("[Anthropic] Extracted structured output from final_result tool (stream)", { chars: text.length });
1629
+ },
1779
1630
  }
1780
- catch {
1781
- args = {};
1631
+ : {}),
1632
+ });
1633
+ // Wrapped rather than folded into the adapter: analytics emission and
1634
+ // tool-execution storage fire ONCE PER STEP in the loop this replaces,
1635
+ // and buildToolResultMessages is the only per-step hook — it receives
1636
+ // exactly that step's results. Doing this from the turn's final result
1637
+ // instead would batch every step's tools into one late write.
1638
+ const adapter = {
1639
+ ...baseAdapter,
1640
+ buildToolResultMessages: (conversation, stepResult, toolResults) => {
1641
+ for (const result of toolResults) {
1642
+ toolsUsed.push(result.name);
1782
1643
  }
1783
- toolCallsForStorage.push({
1644
+ const toolCallsForStorage = toolResults.map((result) => ({
1784
1645
  type: "tool-call",
1785
- toolCallId: acc.id,
1786
- toolName: acc.name,
1787
- args,
1788
- });
1789
- toolsUsed.push(acc.name);
1790
- // Live record lookup, then deferred-catalog auto-hydration: with
1791
- // tools.discovery on, the model may call a cataloged tool it never
1792
- // loaded via search_tools — a real tool, not a hallucination.
1793
- const tool = (toolsRecord[acc.name] ??
1794
- resolveDeferredTool(toolsRecord, acc.name));
1795
- try {
1796
- if (!tool?.execute) {
1797
- throw new Error(`Tool not found: ${acc.name}`);
1646
+ toolCallId: result.id,
1647
+ toolName: result.name,
1648
+ args: result.args,
1649
+ }));
1650
+ const toolResultsForStorage = toolResults.map((result) => result.error
1651
+ ? {
1652
+ type: "tool-result",
1653
+ toolCallId: result.id,
1654
+ toolName: result.name,
1655
+ error: result.error,
1798
1656
  }
1799
- const result = await tool.execute(args, {
1800
- toolCallId: acc.id,
1801
- messages: [],
1802
- });
1803
- toolResultsForStorage.push({
1657
+ : {
1804
1658
  type: "tool-result",
1805
- toolCallId: acc.id,
1806
- toolName: acc.name,
1807
- result,
1808
- });
1809
- resultBlocks.push({
1810
- type: "tool_result",
1811
- tool_use_id: acc.id,
1812
- content: stringifyAnthropicToolOutput(result),
1659
+ toolCallId: result.id,
1660
+ toolName: result.name,
1661
+ result: result.output,
1813
1662
  });
1814
- }
1815
- catch (toolErr) {
1816
- const message = toolErr instanceof Error ? toolErr.message : String(toolErr);
1817
- toolResultsForStorage.push({
1818
- type: "tool-result",
1819
- toolCallId: acc.id,
1820
- toolName: acc.name,
1821
- error: message,
1663
+ emitToolEndFromStepFinish(emitter, toolResultsForStorage.map((tr) => ({
1664
+ toolName: tr.toolName,
1665
+ result: "result" in tr ? tr.result : undefined,
1666
+ error: "error" in tr ? tr.error : undefined,
1667
+ })));
1668
+ this.handleToolExecutionStorage(toolCallsForStorage, toolResultsForStorage, options, new Date()).catch((storageErr) => {
1669
+ logger.warn("[AnthropicProvider] Failed to store tool executions", {
1670
+ provider: this.providerName,
1671
+ error: storageErr instanceof Error
1672
+ ? storageErr.message
1673
+ : String(storageErr),
1822
1674
  });
1823
- resultBlocks.push({
1824
- type: "tool_result",
1825
- tool_use_id: acc.id,
1826
- content: `Error: ${message}`,
1827
- is_error: true,
1675
+ });
1676
+ return baseAdapter.buildToolResultMessages(conversation, stepResult, toolResults);
1677
+ },
1678
+ };
1679
+ // Presented in the shape the engine dispatches through. The engine
1680
+ // supplies `{ toolCallId, abortSignal }`; the loop this replaces also
1681
+ // supplied `messages: []`, so it is kept — a tool that reads it would
1682
+ // otherwise see undefined where it used to see an empty array.
1683
+ const engineTools = {};
1684
+ for (const [name, tool] of Object.entries(toolsRecord)) {
1685
+ const execute = tool.execute;
1686
+ if (!execute) {
1687
+ continue;
1688
+ }
1689
+ engineTools[name] = {
1690
+ execute: async (args, opts) => {
1691
+ const ctx = opts;
1692
+ return execute(args, {
1693
+ toolCallId: ctx.toolCallId ?? "",
1694
+ ...(ctx.abortSignal ? { abortSignal: ctx.abortSignal } : {}),
1695
+ messages: [],
1828
1696
  });
1697
+ },
1698
+ };
1699
+ }
1700
+ const { stream, resultPromise } = runAgenticLoop(adapter, payload.messages.slice(), {
1701
+ tools: engineTools,
1702
+ ...(abortSignal ? { abortSignal } : {}),
1703
+ });
1704
+ // Structured turns buffer their text rather than streaming it: a caller
1705
+ // that passed a schema needs parseable JSON, and deltas emitted before
1706
+ // the model calls final_result would prefix the payload with prose.
1707
+ // Reasoning is forwarded either way — it is not part of the payload.
1708
+ const pump = (async () => {
1709
+ for await (const chunk of stream) {
1710
+ if (chunk.reasoning) {
1711
+ pushChunk({ content: "", reasoning: chunk.reasoning });
1712
+ }
1713
+ if (chunk.content) {
1714
+ if (finalResultActive) {
1715
+ bufferedText += chunk.content;
1716
+ }
1717
+ else {
1718
+ pushChunk({ content: chunk.content });
1719
+ }
1829
1720
  }
1830
1721
  }
1831
- // Emit tool:end events for Pipeline B and persist tool executions —
1832
- // the same hooks the streamText onStepFinish callback used to drive.
1833
- emitToolEndFromStepFinish(emitter, toolResultsForStorage.map((tr) => ({
1834
- toolName: tr.toolName,
1835
- result: tr.result,
1836
- error: tr.error,
1837
- })));
1838
- this.handleToolExecutionStorage(toolCallsForStorage, toolResultsForStorage, options, new Date()).catch((storageErr) => {
1839
- logger.warn("[AnthropicProvider] Failed to store tool executions", {
1840
- provider: this.providerName,
1841
- error: storageErr instanceof Error
1842
- ? storageErr.message
1843
- : String(storageErr),
1844
- });
1845
- });
1846
- conversation.push({ role: "user", content: resultBlocks });
1847
- }
1722
+ })();
1723
+ const result = await resultPromise;
1724
+ await pump;
1725
+ totalInput += result.usage.inputTokens;
1726
+ totalOutput += result.usage.outputTokens;
1727
+ totalCacheRead += result.usage.cacheReadTokens ?? 0;
1728
+ totalCacheWrite += result.usage.cacheWriteTokens ?? 0;
1729
+ lastStop = result.rawStopReason ?? lastStop;
1848
1730
  resolveUsage(buildDeferredUsage());
1849
1731
  resolveFinish(lastStop ?? "stop");
1850
1732
  };
@@ -285,6 +285,9 @@ export function createAnthropicLoopAdapter(config) {
285
285
  const finalText = terminal
286
286
  ? stringifyFinalResultInput(terminal.inputJson)
287
287
  : text;
288
+ if (terminal) {
289
+ config.onTerminalResult?.(finalText);
290
+ }
288
291
  return {
289
292
  text: finalText,
290
293
  ...(reasoning ? { reasoning } : {}),
@@ -1001,7 +1001,16 @@ export class GoogleAIStudioProvider extends BaseProvider {
1001
1001
  if (wantsNativeJsonRequested && exclusionInForce) {
1002
1002
  logger.warn("[GoogleAIStudio] Gemini does not support tools and JSON schema output simultaneously. Disabling tools for this request (generate()).");
1003
1003
  }
1004
- if (shouldUseTools && !exclusionInForce) {
1004
+ // Both conjuncts, matching the warning directly above and the
1005
+ // stream path's gate. `isToolsSchemaExclusionInForce` answers "does
1006
+ // the tools/schema exclusion APPLY to this provider and model", and
1007
+ // is true for any Gemini request that has tools at all — it is not
1008
+ // "the exclusion is triggered". Testing `!exclusionInForce` alone
1009
+ // therefore made this branch reachable only when there were NO
1010
+ // tools, so every caller-supplied tool was dropped on the generate
1011
+ // path whether or not structured output was ever requested.
1012
+ if (shouldUseTools &&
1013
+ !(wantsNativeJsonRequested && exclusionInForce)) {
1005
1014
  const tools = options.tools || {};
1006
1015
  if (Object.keys(tools).length > 0) {
1007
1016
  const result = toNativeToolDeclarations(tools, "functionDeclarations");
@@ -151,6 +151,18 @@ export type AnthropicLoopAdapterConfig = {
151
151
  * ordinary zero-tool-calls exit.
152
152
  */
153
153
  finalResultToolName?: string;
154
+ /**
155
+ * Called with the terminal tool's payload when one was actually detected.
156
+ *
157
+ * The caller cannot infer this from the turn's result. A structured turn
158
+ * ends with the payload in `text` when the model called the terminal tool,
159
+ * and with ordinary prose in `text` when it ignored the instruction and
160
+ * answered directly — the two are indistinguishable downstream, yet they
161
+ * are handled differently: the payload is delivered as the answer, while
162
+ * prose is delivered from the caller's own buffer. Comparing strings to
163
+ * tell them apart would be guesswork, so the adapter says which happened.
164
+ */
165
+ onTerminalResult?: (text: string) => void;
154
166
  toolFailureBreaker?: AgenticLoopToolFailureBreaker;
155
167
  /**
156
168
  * In-turn context reclaim, run once per step before the request is built.