Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to ClawRouter.

---

## v0.12.214 — June 28, 2026

Recover tool calls that Gemini 3.5 Flash emits as plain-text transcripts instead of structured `tool_calls` ([#189](https://github.com/BlockRunAI/ClawRouter/issues/189)).

### Gemini `[Called function "…" with args: {…}]` transcripts now become real tool calls

- **Symptom:** through the OpenAI-compatible path, Gemini 3.5 Flash sometimes narrates a tool call as assistant text — `[Called function "terminal" with args: {"command":"whoami"}]` — instead of returning structured `tool_calls` with `finish_reason: "tool_calls"`. Downstream chat surfaces (seen in Chermes/Telegram) then **displayed the transcript** to the user instead of dispatching the tool, stalling agent loops.
- **Fix:** added a third recognizer to `src/textual-tool-calls.ts` (which already synthesizes structured tool calls from OpenClaw `<tool_call>` and Anthropic `<function_calls>` text shapes). The new extractor locates `[Called function "NAME" with args: ` and parses the JSON args with a **balanced-brace scan** that honors string literals — so commas, braces, and `]` inside the JSON can't truncate the match. It only fires when the args parse as a JSON object **and** the block is closed by `]`, so prose that merely quotes the format doesn't mis-fire. Both call sites — streaming and non-streaming in `proxy.ts` — share this function, so the recovered call is forwarded as a proper `tool_calls` delta/message with empty content and `finish_reason: "tool_calls"`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the trailing space inside the code span.

Line 14 is tripping markdownlint MD038 because the span around [Called function "NAME" with args: includes a trailing space before the closing backtick.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 14-14: Spaces inside code span elements

(MD038, no-space-in-code)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 14, The changelog entry contains a code span with a
trailing space that triggers markdownlint MD038. Update the wording around the
[Called function "NAME" with args:] phrase in CHANGELOG.md so the backticked
span has no trailing space before the closing backtick, while keeping the rest
of the description unchanged.

Source: Linters/SAST tools

- **Tests:** 8 new cases in `src/textual-tool-calls.test.ts` (single/multi-arg, empty args, nested JSON with embedded brackets, multiple transcripts with prose stripping, no-mis-fire on missing bracket / non-object args). Full suite **631 passed**, lint + typecheck + build clean.
- **Out of scope:** the issue's secondary `[Tool "function" returned]: {…}` display line is a downstream chat-surface rendering concern, not a ClawRouter normalization gap — left untouched.

---

## v0.12.213 — June 26, 2026

Fix an HTTP 500 (`Failed to parse payment requirements`) that broke paid Base-chain calls whenever a small request seeded the pre-auth cache before a larger one ([#188](https://github.com/BlockRunAI/ClawRouter/pull/188), thanks [@KillerQueen-Z](https://github.com/KillerQueen-Z)).
Expand Down
65 changes: 62 additions & 3 deletions dist/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -78758,6 +78758,59 @@ function extractAnthropicCalls(content) {
}
return { calls, matches };
}
function scanJsonObject(content, start) {
if (content[start] !== "{") return -1;
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < content.length; i++) {
const ch = content[i];
if (inString) {
if (escaped) escaped = false;
else if (ch === "\\") escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) return i + 1;
}
}
return -1;
}
function extractGeminiCalls(content) {
const calls = [];
const matches = [];
GEMINI_PREFIX_RE.lastIndex = 0;
let match;
while ((match = GEMINI_PREFIX_RE.exec(content)) !== null) {
const name = match[1]?.trim();
const jsonStart = match.index + match[0].length;
if (!name || content[jsonStart] !== "{") continue;
const jsonEnd = scanJsonObject(content, jsonStart);
if (jsonEnd === -1) continue;
let parsed;
try {
parsed = JSON.parse(content.slice(jsonStart, jsonEnd));
} catch {
continue;
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) continue;
let close = jsonEnd;
while (close < content.length && /\s/.test(content[close])) close++;
if (content[close] !== "]") continue;
calls.push({
id: generateId(),
type: "function",
function: { name, arguments: JSON.stringify(parsed) }
});
matches.push({ start: match.index, end: close + 1 });
GEMINI_PREFIX_RE.lastIndex = close + 1;
}
return { calls, matches };
}
function stripRanges(content, ranges) {
if (ranges.length === 0) return content;
const sorted = [...ranges].sort((a, b) => a.start - b.start);
Expand All @@ -78778,14 +78831,19 @@ function extractTextualToolCalls(content) {
}
const openClaw = extractOpenClawCalls(content);
const anthropic = extractAnthropicCalls(content);
const toolCalls = [...openClaw.calls, ...anthropic.calls];
const gemini = extractGeminiCalls(content);
const toolCalls = [...openClaw.calls, ...anthropic.calls, ...gemini.calls];
if (toolCalls.length === 0) {
return { toolCalls: [], cleanedContent: content };
}
const cleanedContent = stripRanges(content, [...openClaw.matches, ...anthropic.matches]);
const cleanedContent = stripRanges(content, [
...openClaw.matches,
...anthropic.matches,
...gemini.matches
]);
return { toolCalls, cleanedContent };
}
var OPENCLAW_TOOL_CALL_RE, OPENCLAW_ARG_RE, ANTHROPIC_BLOCK_RE, ANTHROPIC_INVOKE_RE, ANTHROPIC_PARAM_RE;
var OPENCLAW_TOOL_CALL_RE, OPENCLAW_ARG_RE, ANTHROPIC_BLOCK_RE, ANTHROPIC_INVOKE_RE, ANTHROPIC_PARAM_RE, GEMINI_PREFIX_RE;
var init_textual_tool_calls = __esm({
"src/textual-tool-calls.ts"() {
"use strict";
Expand All @@ -78794,6 +78852,7 @@ var init_textual_tool_calls = __esm({
ANTHROPIC_BLOCK_RE = /<function_calls\b[^>]*>([\s\S]*?)<\/function_calls\s*>/g;
ANTHROPIC_INVOKE_RE = /<invoke\s+name=["']([^"']+)["'][^>]*>([\s\S]*?)<\/invoke\s*>/g;
ANTHROPIC_PARAM_RE = /<parameter\s+name=["']([^"']+)["'][^>]*>([\s\S]*?)<\/parameter\s*>/g;
GEMINI_PREFIX_RE = /\[Called function\s+["']([^"']+)["']\s+with args:\s*/g;
}
});

Expand Down
2 changes: 1 addition & 1 deletion dist/cli.js.map

Large diffs are not rendered by default.

63 changes: 61 additions & 2 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -78315,6 +78315,7 @@ var OPENCLAW_ARG_RE = /<arg_key>([\s\S]*?)<\/arg_key>\s*<arg_value>([\s\S]*?)<\/
var ANTHROPIC_BLOCK_RE = /<function_calls\b[^>]*>([\s\S]*?)<\/function_calls\s*>/g;
var ANTHROPIC_INVOKE_RE = /<invoke\s+name=["']([^"']+)["'][^>]*>([\s\S]*?)<\/invoke\s*>/g;
var ANTHROPIC_PARAM_RE = /<parameter\s+name=["']([^"']+)["'][^>]*>([\s\S]*?)<\/parameter\s*>/g;
var GEMINI_PREFIX_RE = /\[Called function\s+["']([^"']+)["']\s+with args:\s*/g;
function generateId() {
return `call_${randomBytes6(12).toString("base64url")}`;
}
Expand Down Expand Up @@ -78392,6 +78393,59 @@ function extractAnthropicCalls(content) {
}
return { calls, matches };
}
function scanJsonObject(content, start) {
if (content[start] !== "{") return -1;
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < content.length; i++) {
const ch = content[i];
if (inString) {
if (escaped) escaped = false;
else if (ch === "\\") escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) return i + 1;
}
}
return -1;
}
function extractGeminiCalls(content) {
const calls = [];
const matches = [];
GEMINI_PREFIX_RE.lastIndex = 0;
let match;
while ((match = GEMINI_PREFIX_RE.exec(content)) !== null) {
const name = match[1]?.trim();
const jsonStart = match.index + match[0].length;
if (!name || content[jsonStart] !== "{") continue;
const jsonEnd = scanJsonObject(content, jsonStart);
if (jsonEnd === -1) continue;
let parsed;
try {
parsed = JSON.parse(content.slice(jsonStart, jsonEnd));
} catch {
continue;
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) continue;
let close = jsonEnd;
while (close < content.length && /\s/.test(content[close])) close++;
if (content[close] !== "]") continue;
calls.push({
id: generateId(),
type: "function",
function: { name, arguments: JSON.stringify(parsed) }
});
matches.push({ start: match.index, end: close + 1 });
GEMINI_PREFIX_RE.lastIndex = close + 1;
}
return { calls, matches };
}
function stripRanges(content, ranges) {
if (ranges.length === 0) return content;
const sorted = [...ranges].sort((a, b) => a.start - b.start);
Expand All @@ -78412,11 +78466,16 @@ function extractTextualToolCalls(content) {
}
const openClaw = extractOpenClawCalls(content);
const anthropic = extractAnthropicCalls(content);
const toolCalls = [...openClaw.calls, ...anthropic.calls];
const gemini = extractGeminiCalls(content);
const toolCalls = [...openClaw.calls, ...anthropic.calls, ...gemini.calls];
if (toolCalls.length === 0) {
return { toolCalls: [], cleanedContent: content };
}
const cleanedContent = stripRanges(content, [...openClaw.matches, ...anthropic.matches]);
const cleanedContent = stripRanges(content, [
...openClaw.matches,
...anthropic.matches,
...gemini.matches
]);
return { toolCalls, cleanedContent };
}

Expand Down
2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@blockrun/clawrouter",
"version": "0.12.213",
"version": "0.12.214",
"description": "Smart LLM router — save 85% on inference costs. 55+ models (8 free), one wallet, x402 micropayments.",
"type": "module",
"main": "dist/index.js",
Expand Down
75 changes: 75 additions & 0 deletions src/textual-tool-calls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,81 @@ describe("extractTextualToolCalls", () => {
});
});

describe('Gemini [Called function "NAME" with args: {...}] transcript format', () => {
it("extracts a single tool call (issue #189 repro)", () => {
const content = '[Called function "terminal" with args: {"command":"whoami"}]';
const result = extractTextualToolCalls(content);
expect(result.toolCalls).toHaveLength(1);
expect(result.toolCalls[0]?.function.name).toBe("terminal");
expect(JSON.parse(result.toolCalls[0]!.function.arguments)).toEqual({ command: "whoami" });
expect(result.cleanedContent).toBe("");
});

it("extracts a call with multiple args", () => {
const content =
'[Called function "search_files" with args: {"pattern":"*","target":"files"}]';
const result = extractTextualToolCalls(content);
expect(result.toolCalls).toHaveLength(1);
expect(result.toolCalls[0]?.function.name).toBe("search_files");
expect(JSON.parse(result.toolCalls[0]!.function.arguments)).toEqual({
pattern: "*",
target: "files",
});
});

it("extracts an empty-args call", () => {
const content = '[Called function "list" with args: {}]';
const result = extractTextualToolCalls(content);
expect(result.toolCalls).toHaveLength(1);
expect(result.toolCalls[0]?.function.name).toBe("list");
expect(JSON.parse(result.toolCalls[0]!.function.arguments)).toEqual({});
});

it("handles nested JSON objects and brackets in args without truncating", () => {
const content =
'[Called function "write" with args: {"path":"a]b","data":{"k":[1,2,{"x":"}"}]}}]';
const result = extractTextualToolCalls(content);
expect(result.toolCalls).toHaveLength(1);
expect(JSON.parse(result.toolCalls[0]!.function.arguments)).toEqual({
path: "a]b",
data: { k: [1, 2, { x: "}" }] },
});
expect(result.cleanedContent).toBe("");
});

it("extracts multiple transcripts and strips surrounding prose", () => {
const content =
'Let me check.\n[Called function "a" with args: {"q":1}]\nThen:\n[Called function "b" with args: {"q":2}]';
const result = extractTextualToolCalls(content);
expect(result.toolCalls).toHaveLength(2);
expect(result.toolCalls.map((c) => c.function.name)).toEqual(["a", "b"]);
expect(result.cleanedContent).not.toContain("Called function");
expect(result.cleanedContent).toContain("Let me check.");
expect(result.cleanedContent).toContain("Then:");
});

it("generates OpenAI-shaped ids", () => {
const content = '[Called function "x" with args: {"q":1}]';
const result = extractTextualToolCalls(content);
expect(result.toolCalls[0]?.id).toMatch(/^call_[A-Za-z0-9_-]+$/);
expect(result.toolCalls[0]?.type).toBe("function");
});

it("does NOT mis-fire without a closing bracket", () => {
const content = '[Called function "x" with args: {"q":1}';
const result = extractTextualToolCalls(content);
expect(result.toolCalls).toHaveLength(0);
expect(result.cleanedContent).toBe(content);
});

it("does NOT mis-fire when args is not a JSON object", () => {
const content = '[Called function "x" with args: "whoami"]';
const result = extractTextualToolCalls(content);
expect(result.toolCalls).toHaveLength(0);
expect(result.cleanedContent).toBe(content);
});
});

describe("Negative cases (must NOT mis-fire)", () => {
it("returns empty toolCalls when no tool-call XML present", () => {
const result = extractTextualToolCalls("Just a normal sentence.");
Expand Down
Loading
Loading