/**
* Recovery for tool calls a native-tool-calling model emitted as plain text.
*
* Symptom: the provider returns a normal `end_turn` whose visible text carries
* a fully-formed Anthropic-style `…` block instead of
* native `tool_use` content. The turn then holds zero runnable tool calls, so
* the agent loop stops with the markup rendered verbatim to the user and the
* announced work never runs.
*
* This module re-materializes such a turn into real `toolCall` blocks using the
* same in-band scanner the owned-dialect path uses, so the loop dispatches them
* unchanged. It is deliberately conservative: a turn is salvaged ONLY when the
* markup is unambiguously a call the model meant to make (see
* {@link salvageLeakedToolCalls}), never when it is quoted documentation.
*/
import type { AssistantMessage, ToolCall } from "../types";
import { computeFenceRanges, isInsideFence } from "../utils/fences";
import { parseInbandToolMessage } from "./owned-stream";
import type { InbandTool } from "./types";
/** Matches an opening invoke tag, with or without a dialect prefix (`x:invoke`). */
const INVOKE_OPEN_RE = /<\s*(?:[A-Za-z_][\w.-]*:)?invoke\b/g;
export interface SalvagedToolCalls {
/** The rewritten turn: markup removed from text, real `toolCall` blocks appended. */
readonly message: AssistantMessage;
/** The calls recovered from the leaked markup, in emission order. */
readonly calls: readonly ToolCall[];
}
/**
* Recover tool calls a model wrote as visible text instead of native tool_use.
*
* Returns `undefined` unless EVERY guard holds — each one exists to keep a turn
* that merely *talks about* tool syntax from being executed:
*
* - The turn ended normally (`stop`). `length` may have truncated the markup
* mid-argument, and `error`/`aborted` turns are already handled upstream.
* - The turn carries no native `toolCall` block. A model that called tools
* natively AND quoted markup is documenting, not leaking.
* - At least one ` block.type === "toolCall")) return undefined;
let text = "";
for (const block of message.content) {
if (block.type === "text") text += block.text;
}
if (text.length === 0) return undefined;
const fences = computeFenceRanges(text);
let bare = 0;
INVOKE_OPEN_RE.lastIndex = 0;
for (const match of text.matchAll(INVOKE_OPEN_RE)) {
if (isInsideFence(fences, match.index ?? 0)) return undefined;
bare++;
}
if (bare === 0) return undefined;
const parsed = parseInbandToolMessage(message, "anthropic", tools);
const calls = parsed.content.filter((block): block is ToolCall => block.type === "toolCall");
if (calls.length === 0) return undefined;
const available = new Set();
for (const tool of tools) {
available.add(tool.name);
if (tool.customWireName !== undefined) available.add(tool.customWireName);
}
if (!calls.every(call => available.has(call.name))) return undefined;
// `stopReason` is restated rather than inherited: the projector happens to
// derive `toolUse` today, but this function's contract is "the loop must see
// a runnable tool turn, not a finished answer" — that must not silently
// depend on an internal of the owned-stream projector.
return { message: { ...parsed, stopReason: "toolUse" }, calls };
}