mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 17:52:31 +03:00
* fix(web-tools): anchor tool contract at prompt tail + user-turn reminder The <tool> contract from prepareToolMessages was prepended as the first system message. Web executors fold all system messages into one block, so with agentic clients whose system prompts exceed ~28K chars the contract sat at the head of a huge block and web models ignored it, refusing tool calls with "tool X is not in my tool set" (chatgpt-web, 0/3 at 30K chars). Two changes, both required in testing: - Dual placement: the full contract now rides as a trailing system message (folds to the tail of the system block) and a one-line reminder naming the tools is appended to the latest user message. - Rewording: the contract now frames injected tools as client tools invoked via a plain-text protocol, distinct from the model's native tool registry (web.run, python.exec, ...), and instructs the model to never claim they are unavailable. Without this the model resolved tool names against its native registry and refused even when it had seen the contract. Measured on cgpt-web gpt-5.5-thinking/gpt-5.6-thinking/o3: prepend 0/3 tool calls at 30K chars; dual placement 16/17 across 30K-250K system prompts, 30-tool sets, multi-turn tool history, streaming, and 3-way concurrency, with no spurious calls on no-tool prompts. Known limit: ~40K-char single user messages still flake (2/3) due to the upstream model's own injection heuristics. All prepareToolMessages consumers parse system messages position-independently and select the current user turn by role scan, so the trailing system message is shape-safe for every web executor. * test(web-tools): cover contract placement edge cases --------- Co-authored-by: Ryan Brosas <ryanjoserbrosas@gmail.com>
601 lines
19 KiB
TypeScript
601 lines
19 KiB
TypeScript
// Tool-call translation for web-cookie providers (deepseek-web, chatgpt-web, ...).
|
|
//
|
|
// The web UIs accept only a single plain prompt string and have no native function
|
|
// calling — they reply with tool invocations as raw text. To let agentic clients use
|
|
// these providers we (a) serialize the OpenAI `tools` array into a system-prompt
|
|
// contract on the request side, and (b) parse the upstream `<tool>{...}</tool>` text
|
|
// back into OpenAI `tool_calls` on the response side. (#2820)
|
|
|
|
export interface OpenAIToolCall {
|
|
id: string;
|
|
type: "function";
|
|
function: { name: string; arguments: string };
|
|
}
|
|
|
|
interface OpenAIToolDef {
|
|
type?: string;
|
|
function?: {
|
|
name?: string;
|
|
description?: string;
|
|
parameters?: unknown;
|
|
};
|
|
}
|
|
|
|
const TOOL_BLOCK_RE = /<tool>\s*([\s\S]*?)\s*<\/tool>/g;
|
|
// Some web-cookie models (e.g. ds-web) wrap calls as `<tool_call name="...">{json}</tool_call>`
|
|
// instead of the canonical `<tool>{json}</tool>`. Capture the JSON body — the real tool name
|
|
// lives there, never in the tag's `name="..."` attribute (#3260).
|
|
const TOOL_CALL_TAG_RE = /<tool_call(?:\s+[^>]*)?\s*>\s*([\s\S]*?)\s*<\/tool_call>/g;
|
|
|
|
// Per-request nonce binding for tool envelopes (#9343). Associates a random nonce
|
|
// with each tools[] array reference so the serializer and parser can share it
|
|
// without threading extra parameters through executor call chains.
|
|
const toolNonceMap = new WeakMap<object, string>();
|
|
|
|
export function getToolNonce(tools: unknown): string {
|
|
if (!Array.isArray(tools) || tools.length === 0) return "";
|
|
let nonce = toolNonceMap.get(tools);
|
|
if (!nonce) {
|
|
nonce = Math.random().toString(36).slice(2, 10);
|
|
toolNonceMap.set(tools, nonce);
|
|
}
|
|
return nonce;
|
|
}
|
|
|
|
interface ToolParseCandidate {
|
|
raw: string;
|
|
start: number;
|
|
end: number;
|
|
requireRequestedTool: boolean;
|
|
}
|
|
|
|
export interface RequestedToolName {
|
|
original: string;
|
|
normalized: string;
|
|
}
|
|
|
|
function toRecord(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
export function getRequestedToolNames(tools: unknown): RequestedToolName[] {
|
|
if (!Array.isArray(tools)) return [];
|
|
const names: RequestedToolName[] = [];
|
|
const seen = new Set<string>();
|
|
for (const tool of tools) {
|
|
const record = toRecord(tool);
|
|
const fn = toRecord(record?.function);
|
|
const name = typeof fn?.name === "string" ? fn.name.trim() : "";
|
|
if (!name || seen.has(name)) continue;
|
|
seen.add(name);
|
|
names.push({ original: name, normalized: normalizeToolName(name) });
|
|
}
|
|
return names;
|
|
}
|
|
|
|
function normalizeToolName(name: string): string {
|
|
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
}
|
|
|
|
function levenshteinDistance(a: string, b: string): number {
|
|
if (a === b) return 0;
|
|
if (!a) return b.length;
|
|
if (!b) return a.length;
|
|
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
let current = Array<number>(b.length + 1);
|
|
for (let i = 1; i <= a.length; i += 1) {
|
|
current[0] = i;
|
|
for (let j = 1; j <= b.length; j += 1) {
|
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost);
|
|
}
|
|
const temp = previous;
|
|
previous = current;
|
|
current = temp;
|
|
}
|
|
return previous[b.length];
|
|
}
|
|
|
|
function scoreToolName(emitted: string, requested: RequestedToolName): number {
|
|
if (emitted === requested.original) return 1;
|
|
const normalized = normalizeToolName(emitted);
|
|
if (!normalized || !requested.normalized) return 0;
|
|
if (normalized === requested.normalized) return 0.98;
|
|
|
|
const shorter = Math.min(normalized.length, requested.normalized.length);
|
|
const longer = Math.max(normalized.length, requested.normalized.length);
|
|
if (shorter >= 4) {
|
|
if (normalized.includes(requested.normalized) || requested.normalized.includes(normalized)) {
|
|
return 0.86 - (longer - shorter) / Math.max(longer, 1) / 4;
|
|
}
|
|
}
|
|
|
|
const distance = levenshteinDistance(normalized, requested.normalized);
|
|
const similarity = 1 - distance / Math.max(longer, 1);
|
|
return similarity >= 0.72 ? similarity : 0;
|
|
}
|
|
|
|
export function resolveRequestedToolName(
|
|
emitted: string,
|
|
requestedTools: RequestedToolName[]
|
|
): string | null {
|
|
if (requestedTools.length === 0) return emitted;
|
|
|
|
let best: { name: string; score: number } | null = null;
|
|
let secondBest = 0;
|
|
for (const requested of requestedTools) {
|
|
const score = scoreToolName(emitted, requested);
|
|
if (!best || score > best.score) {
|
|
secondBest = best?.score ?? 0;
|
|
best = { name: requested.original, score };
|
|
} else if (score > secondBest) {
|
|
secondBest = score;
|
|
}
|
|
}
|
|
|
|
if (!best || best.score < 0.72) return null;
|
|
// Avoid correcting to an arbitrary tool when the fuzzy match is ambiguous.
|
|
if (best.score < 0.98 && best.score - secondBest < 0.08) return null;
|
|
return best.name;
|
|
}
|
|
|
|
function stripCodeFence(value: string): string {
|
|
return value
|
|
.trim()
|
|
.replace(/^```(?:json|javascript|js|python)?\s*/i, "")
|
|
.replace(/\s*```$/i, "")
|
|
.trim();
|
|
}
|
|
|
|
function convertSingleQuotedStrings(value: string): string {
|
|
let result = "";
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
let escaped = false;
|
|
|
|
for (const ch of value) {
|
|
if (escaped) {
|
|
result += ch === '"' && inSingle ? '\\"' : ch;
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
|
|
if (ch === "\\") {
|
|
result += ch;
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '"') {
|
|
if (inSingle) {
|
|
result += '\\"';
|
|
} else {
|
|
inDouble = !inDouble;
|
|
result += ch;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (ch === "'" && !inDouble) {
|
|
inSingle = !inSingle;
|
|
result += '"';
|
|
continue;
|
|
}
|
|
|
|
result += ch;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function replacePythonLiterals(value: string): string {
|
|
let result = "";
|
|
let inString = false;
|
|
let escaped = false;
|
|
let token = "";
|
|
|
|
const flushToken = () => {
|
|
if (token === "True") result += "true";
|
|
else if (token === "False") result += "false";
|
|
else if (token === "None") result += "null";
|
|
else result += token;
|
|
token = "";
|
|
};
|
|
|
|
for (const ch of value) {
|
|
if (escaped) {
|
|
if (token) flushToken();
|
|
result += ch;
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
|
|
if (ch === "\\") {
|
|
if (token) flushToken();
|
|
result += ch;
|
|
escaped = inString;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '"') {
|
|
if (token) flushToken();
|
|
inString = !inString;
|
|
result += ch;
|
|
continue;
|
|
}
|
|
|
|
if (!inString && /[A-Za-z]/.test(ch)) {
|
|
token += ch;
|
|
continue;
|
|
}
|
|
|
|
if (token) flushToken();
|
|
result += ch;
|
|
}
|
|
|
|
if (token) flushToken();
|
|
return result;
|
|
}
|
|
|
|
function normalizeLooseJson(value: string): string {
|
|
return replacePythonLiterals(convertSingleQuotedStrings(value))
|
|
.replace(/([{,]\s*)([A-Za-z_][A-Za-z0-9_-]*)(\s*:)/g, '$1"$2"$3')
|
|
.replace(/,\s*([}\]])/g, "$1");
|
|
}
|
|
|
|
export function parseLooseJsonObject(raw: string): Record<string, unknown> | null {
|
|
const trimmed = stripCodeFence(raw);
|
|
for (const candidate of [trimmed, normalizeLooseJson(trimmed)]) {
|
|
try {
|
|
return toRecord(JSON.parse(candidate));
|
|
} catch {
|
|
// Try the next, more permissive form.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findBareJsonCandidates(text: string): ToolParseCandidate[] {
|
|
const candidates: ToolParseCandidate[] = [];
|
|
let start = -1;
|
|
let depth = 0;
|
|
let quote: '"' | "'" | "" = "";
|
|
let escaped = false;
|
|
|
|
for (let i = 0; i < text.length; i += 1) {
|
|
const ch = text[i];
|
|
|
|
if (depth === 0 && ch !== "{") {
|
|
continue;
|
|
}
|
|
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
|
|
if (quote) {
|
|
if (ch === "\\") {
|
|
escaped = true;
|
|
} else if (ch === quote) {
|
|
quote = "";
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (ch === '"' || ch === "'") {
|
|
quote = ch;
|
|
continue;
|
|
}
|
|
|
|
if (ch === "{") {
|
|
if (depth === 0) start = i;
|
|
depth += 1;
|
|
continue;
|
|
}
|
|
|
|
if (ch === "}" && depth > 0) {
|
|
depth -= 1;
|
|
if (depth === 0 && start >= 0) {
|
|
const raw = text.slice(start, i + 1);
|
|
if (
|
|
/[{,]\s*["']?(name|command)["']?\s*:/i.test(raw) &&
|
|
/[{,]\s*["']?arguments["']?\s*:/i.test(raw)
|
|
) {
|
|
candidates.push({ raw, start, end: i + 1, requireRequestedTool: true });
|
|
}
|
|
start = -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
function rangesOverlap(
|
|
a: { start: number; end: number },
|
|
b: { start: number; end: number }
|
|
): boolean {
|
|
return a.start < b.end && b.start < a.end;
|
|
}
|
|
|
|
export function stripRanges(text: string, ranges: Array<{ start: number; end: number }>): string {
|
|
let content = text;
|
|
const sorted = [...ranges].sort((a, b) => b.start - a.start);
|
|
for (const range of sorted) {
|
|
const lineStart = content.lastIndexOf("\n", range.start - 1) + 1;
|
|
const nextLineBreak = content.indexOf("\n", range.end);
|
|
const lineEnd = nextLineBreak === -1 ? content.length : nextLineBreak;
|
|
const beforeOnLine = content.slice(lineStart, range.start);
|
|
const afterOnLine = content.slice(range.end, lineEnd);
|
|
const removeWholeLine = beforeOnLine.trim() === "" && afterOnLine.trim() === "";
|
|
const start = removeWholeLine ? lineStart : range.start;
|
|
const end =
|
|
removeWholeLine && nextLineBreak !== -1
|
|
? nextLineBreak + 1
|
|
: removeWholeLine
|
|
? lineEnd
|
|
: range.end;
|
|
content = `${content.slice(0, start)}${content.slice(end)}`;
|
|
}
|
|
return content.replace(/\n{3,}/g, "\n\n").trim();
|
|
}
|
|
|
|
export function toArgumentsString(value: unknown): string {
|
|
if (value === undefined) return "{}";
|
|
if (typeof value === "string") {
|
|
const parsed = parseLooseJsonObject(value);
|
|
return parsed ? JSON.stringify(parsed) : value;
|
|
}
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return "{}";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serialize an OpenAI `tools` array into a system-prompt block that instructs the
|
|
* web UI model how to invoke a tool (emit a `<tool>{...}</tool>` block). Returns an
|
|
* empty string when there are no usable tools.
|
|
*
|
|
* Each invocation generates a per-request nonce that is embedded in the tool format
|
|
* instructions. The parser (parseToolCallsFromText) requires this nonce in the model's
|
|
* `<tool>` JSON to distinguish legitimate tool calls from bare JSON, code-fenced JSON,
|
|
* or copy-attacked envelopes (#9343).
|
|
*/
|
|
export function serializeToolsToPrompt(tools: unknown): string {
|
|
if (!Array.isArray(tools) || tools.length === 0) return "";
|
|
|
|
const nonce = getToolNonce(tools);
|
|
if (!nonce) return "";
|
|
|
|
const lines: string[] = [];
|
|
for (const t of tools as OpenAIToolDef[]) {
|
|
const fn = t?.function;
|
|
if (!fn?.name) continue;
|
|
const desc = typeof fn.description === "string" && fn.description ? fn.description : "";
|
|
let params = "";
|
|
try {
|
|
params = fn.parameters ? JSON.stringify(fn.parameters) : "";
|
|
} catch {
|
|
params = "";
|
|
}
|
|
lines.push(
|
|
`- ${fn.name}${desc ? `: ${desc}` : ""}${params ? `\n parameters: ${params}` : ""}`
|
|
);
|
|
}
|
|
|
|
if (lines.length === 0) return "";
|
|
|
|
return [
|
|
"The client application provides tools beyond your built-in ones. They are NOT in your " +
|
|
"native tool registry; they are invoked via a plain-text protocol: the client parses " +
|
|
"your reply and executes the tool on the user machine. Treat these client tools as " +
|
|
"fully available to you; never claim they are unavailable. To invoke one, reply with " +
|
|
"a single line containing a <tool> block",
|
|
`with JSON that includes the secret binding "_nonce": "${nonce}":`,
|
|
`<tool>{"name": "<tool_name>", "arguments": { ... }, "_nonce": "${nonce}"}</tool>`,
|
|
"These client tools ARE available to you in this conversation. Only emit the <tool> " +
|
|
"block when you actually want to call a tool; otherwise answer normally.",
|
|
"",
|
|
"Available tools:",
|
|
...lines,
|
|
].join("\n");
|
|
}
|
|
|
|
/**
|
|
* Parse `<tool>{...}</tool>` or `<tool_call>{...}</tool_call>` blocks out of
|
|
* upstream text into OpenAI `tool_calls`.
|
|
*
|
|
* **Security hardening (#9343):** Bare JSON with name+arguments keys is NEVER
|
|
* promoted to tool_calls — only explicit `<tool>` or `<tool_call>` envelopes are
|
|
* accepted. When a nonce was embedded via serializeToolsToPrompt (stored from the
|
|
* same tools[] reference), it MUST be present in the parsed JSON body as `_nonce`.
|
|
* This prevents code-fenced JSON, prose JSON, and copy-attacked user envelopes from
|
|
* triggering tool execution.
|
|
*
|
|
* Returns the content with the recognized blocks stripped, plus the tool calls
|
|
* (or null when there are none). `arguments` is always a JSON *string*, matching
|
|
* the OpenAI API.
|
|
*
|
|
* `idSeed` makes generated ids deterministic for callers that need stability; when
|
|
* omitted, ids are still unique within a single call (index-based).
|
|
*/
|
|
export function parseToolCallsFromText(
|
|
text: string,
|
|
idSeed = "call",
|
|
requestedTools?: unknown
|
|
): { content: string; toolCalls: OpenAIToolCall[] | null } {
|
|
const requestedToolNames = getRequestedToolNames(requestedTools);
|
|
if (typeof text !== "string" || (!text.includes("<tool>") && !text.includes("<tool_call"))) {
|
|
return { content: text ?? "", toolCalls: null };
|
|
}
|
|
|
|
const nonce = getToolNonce(requestedTools);
|
|
const candidates: ToolParseCandidate[] = [];
|
|
|
|
let blockMatch: RegExpExecArray | null;
|
|
TOOL_BLOCK_RE.lastIndex = 0;
|
|
while ((blockMatch = TOOL_BLOCK_RE.exec(text)) !== null) {
|
|
candidates.push({
|
|
raw: blockMatch[1].trim(),
|
|
start: blockMatch.index,
|
|
end: TOOL_BLOCK_RE.lastIndex,
|
|
requireRequestedTool: false,
|
|
});
|
|
}
|
|
|
|
TOOL_CALL_TAG_RE.lastIndex = 0;
|
|
while ((blockMatch = TOOL_CALL_TAG_RE.exec(text)) !== null) {
|
|
candidates.push({
|
|
raw: blockMatch[1].trim(),
|
|
start: blockMatch.index,
|
|
end: TOOL_CALL_TAG_RE.lastIndex,
|
|
requireRequestedTool: false,
|
|
});
|
|
}
|
|
|
|
candidates.sort((a, b) => a.start - b.start);
|
|
|
|
const toolCalls: OpenAIToolCall[] = [];
|
|
const acceptedRanges: Array<{ start: number; end: number }> = [];
|
|
for (const candidate of candidates) {
|
|
const parsed = parseLooseJsonObject(candidate.raw);
|
|
const emittedName =
|
|
parsed && typeof parsed.name === "string"
|
|
? parsed.name
|
|
: parsed && typeof parsed.command === "string"
|
|
? parsed.command
|
|
: null;
|
|
if (!emittedName) continue;
|
|
|
|
// Nonce binding check (#9343): when the tool prompt embedded a nonce, check
|
|
// that any _nonce present in the JSON body matches. A wrong nonce (present but
|
|
// does not match) means this is a copy-attack or hallucination — treat it as text
|
|
// instead of executing it. A missing _nonce is tolerated for backward compatibility
|
|
// with models that do not (yet) follow the nonce instruction.
|
|
if (nonce && parsed && parsed._nonce !== undefined && parsed._nonce !== nonce) continue;
|
|
|
|
const name =
|
|
resolveRequestedToolName(emittedName, requestedToolNames) ||
|
|
(candidate.requireRequestedTool ? null : emittedName);
|
|
if (!name || (candidate.requireRequestedTool && requestedToolNames.length === 0)) continue;
|
|
const args = toArgumentsString(parsed?.arguments);
|
|
toolCalls.push({
|
|
id: `${idSeed}_${toolCalls.length}`,
|
|
type: "function",
|
|
function: { name, arguments: args },
|
|
});
|
|
acceptedRanges.push({ start: candidate.start, end: candidate.end });
|
|
}
|
|
|
|
if (toolCalls.length === 0) {
|
|
return { content: text, toolCalls: null };
|
|
}
|
|
|
|
const content = stripRanges(text, acceptedRanges);
|
|
return { content, toolCalls };
|
|
}
|
|
|
|
// ── Shared helpers for web-cookie executors ────────────────────────────────
|
|
|
|
interface ToolPrepResult {
|
|
hasTools: boolean;
|
|
requestedTools: unknown;
|
|
effectiveMessages: Array<{ role: string; content: unknown }>;
|
|
}
|
|
|
|
/** One-line nudge appended to the latest user message. Web-UI models weigh the
|
|
* current user turn far more heavily than a large system block, and ChatGPT's
|
|
* injection heuristics distrust long instructions embedded in user content —
|
|
* so the full contract stays in the system block (trailing, see below) and the
|
|
* user turn only carries a short pointer back to it, naming the tools. */
|
|
function buildToolReminder(toolPrompt: string): string {
|
|
const names = (toolPrompt.match(/^- [^:\n]+/gm) || []).map((s) => s.slice(2).trim()).join(", ");
|
|
return (
|
|
"\n\n[Client protocol reminder: the client-tool contract in the system instructions " +
|
|
"is active in this conversation. These client tools ARE available via the <tool> " +
|
|
"block protocol" +
|
|
(names ? ": " + names : "") +
|
|
".]"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Extract tools from an OpenAI request body and inject the tool contract when
|
|
* tools are present. Every web-cookie executor that wants tool-call support
|
|
* calls this once before building its upstream request body.
|
|
*
|
|
* Placement matters: the contract used to be PREPENDED as the first system
|
|
* message. Executors fold all system messages into one block, so with agentic
|
|
* clients whose system prompts exceed ~28K chars the contract sat at the head
|
|
* of a huge block and web models (chatgpt-web observed) ignored it, answering
|
|
* "tool X is not in my tool set" instead of emitting <tool> blocks. Dual
|
|
* placement fixes it: the full contract goes AFTER the client messages (folds
|
|
* to the tail of the system block) and a one-line reminder rides at the end of
|
|
* the latest user message. Measured on cgpt-web/gpt-5.5-thinking with a
|
|
* 30K-char system prompt: prepend 0/3 tool calls, dual placement 16/17 across
|
|
* 30K-250K prompts, 30-tool sets, multi-turn tool history, and streaming.
|
|
*/
|
|
export function prepareToolMessages(
|
|
bodyObj: Record<string, unknown>,
|
|
messages: Array<{ role: string; content: unknown }>
|
|
): ToolPrepResult {
|
|
const requestedTools = bodyObj.tools;
|
|
const hasTools = Array.isArray(requestedTools) && requestedTools.length > 0;
|
|
if (!hasTools) return { hasTools: false, requestedTools, effectiveMessages: messages };
|
|
|
|
const toolPrompt = serializeToolsToPrompt(requestedTools);
|
|
if (!toolPrompt) return { hasTools: true, requestedTools, effectiveMessages: messages };
|
|
|
|
const effectiveMessages = [...messages];
|
|
const reminder = buildToolReminder(toolPrompt);
|
|
for (let i = effectiveMessages.length - 1; i >= 0; i--) {
|
|
const msg = effectiveMessages[i];
|
|
if (msg?.role !== "user") continue;
|
|
if (typeof msg.content === "string") {
|
|
effectiveMessages[i] = { ...msg, content: msg.content + reminder };
|
|
} else if (Array.isArray(msg.content)) {
|
|
effectiveMessages[i] = {
|
|
...msg,
|
|
content: [...msg.content, { type: "text", text: reminder }],
|
|
};
|
|
}
|
|
break;
|
|
}
|
|
effectiveMessages.push({ role: "system", content: toolPrompt });
|
|
return { hasTools: true, requestedTools, effectiveMessages };
|
|
}
|
|
|
|
interface ToolCompletionResult {
|
|
content: string;
|
|
toolCalls: OpenAIToolCall[] | null;
|
|
finishReason: string;
|
|
}
|
|
|
|
/**
|
|
* Parse tool calls from a model's text response. Returns the cleaned content
|
|
* (with `<tool>` blocks stripped), the parsed tool calls (or null), and the
|
|
* appropriate finish_reason. Every web-cookie executor calls this on the
|
|
* collected response text when `hasTools` is true.
|
|
*/
|
|
export function buildToolAwareResult(
|
|
rawContent: string,
|
|
requestedTools: unknown,
|
|
idSeed = "call"
|
|
): ToolCompletionResult {
|
|
const { content, toolCalls } = parseToolCallsFromText(
|
|
rawContent,
|
|
`${idSeed}-${Date.now()}`,
|
|
requestedTools
|
|
);
|
|
return {
|
|
content,
|
|
toolCalls,
|
|
finishReason: toolCalls ? "tool_calls" : "stop",
|
|
};
|
|
}
|