Skip to content

Commit 4e63409

Browse files
authored
fix: suppress tool-call planning content (#161)
Thanks @0xCheetah1! - Suppress assistant content when upstream returns tool_calls in the same response (Kimi/moonshot planning prose was leaking to chat surfaces). - Preserve tool_calls so tool execution works normally. - Declare mcp.servers.blockrun as a noop reload prefix so injectModelsConfig() stops triggering a full OpenClaw gateway restart loop. - Add regression test for the tool-call content suppression. Verified on VPS via moonshot/kimi-k2.6 through OpenClaw Telegram by the contributor.
1 parent 3c00b45 commit 4e63409

6 files changed

Lines changed: 153 additions & 51 deletions

File tree

skills/clawrouter/SKILL.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,11 @@ Realtime prices and historical OHLC across every asset class. The agent should c
129129

130130
### Image & Video Generation
131131

132-
| Tool | Purpose | Price |
133-
| ----------------------------- | ----------------------------------------------------------------------- | ---------------------- |
134-
| `blockrun_image_generation` | 8 image models — DALL-E 3, Nano Banana / Pro, Flux, Grok Imagine, CogView-4 | $0.015–$0.15 / image |
135-
| `blockrun_image_edit` | Edit / inpaint existing image (openai/gpt-image-1) | $0.02–$0.04 / image |
136-
| `blockrun_video_generation` | Grok Imagine + ByteDance Seedance (1.5-pro / 2.0-fast / 2.0), 5–10s | $0.03–$0.30 / second |
132+
| Tool | Purpose | Price |
133+
| --------------------------- | --------------------------------------------------------------------------- | -------------------- |
134+
| `blockrun_image_generation` | 8 image models — DALL-E 3, Nano Banana / Pro, Flux, Grok Imagine, CogView-4 | $0.015–$0.15 / image |
135+
| `blockrun_image_edit` | Edit / inpaint existing image (openai/gpt-image-1) | $0.02–$0.04 / image |
136+
| `blockrun_video_generation` | Grok Imagine + ByteDance Seedance (1.5-pro / 2.0-fast / 2.0), 5–10s | $0.03–$0.30 / second |
137137

138138
### Polymarket (Predexon)
139139

skills/imagegen/SKILL.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ metadata: { "openclaw": { "emoji": "🖼️", "requires": { "config": ["models.p
99
Generate or edit images through ClawRouter. Payment is automatic via x402.
1010

1111
**Shortcuts:**
12+
1213
- Slash: `/imagegen <prompt> [--model=<alias>] [--size=1024x1024] [--n=1]`
1314
- Partner tool: `blockrun_image_generation` (LLM-callable) / `blockrun_image_edit` (inpainting)
1415

@@ -40,16 +41,16 @@ Display inline: `![generated image](http://localhost:8402/images/abc123.png)`
4041

4142
### Model Selection
4243

43-
| Alias | Full ID | Price | Sizes | Best for |
44-
| ------------------ | ----------------------------- | -------------- | --------------------------------- | ------------------------------------- |
45-
| `nano-banana` | `google/nano-banana` | $0.05 | 1024×1024, 1216×832, 1024×1792 | Default — fast, cheap, good quality |
46-
| `banana-pro` | `google/nano-banana-pro` | $0.10–$0.15 | up to 4096×4096 | High-res, large format |
47-
| `dalle` | `openai/dall-e-3` | $0.04–$0.08 | 1024×1024, 1792×1024, 1024×1792 | Photorealistic, complex scenes |
48-
| `gpt-image` | `openai/gpt-image-1` | $0.02–$0.04 | 1024×1024, 1536×1024, 1024×1536 | Budget option; supports editing |
49-
| `flux` | `black-forest/flux-1.1-pro` | $0.04 | 1024×1024, 1216×832, 832×1216 | Artistic styles, fewer restrictions |
50-
| `grok-imagine` | `xai/grok-imagine-image` | $0.02 | 1024×1024 | xAI Grok image style |
51-
| `grok-imagine-pro` | `xai/grok-imagine-image-pro` | $0.07 | 1024×1024 | Grok high-quality |
52-
| `cogview` | `zai/cogview-4` | $0.015–$0.02 | 512×512 to 1440×1440 | Cheapest — Zhipu CogView |
44+
| Alias | Full ID | Price | Sizes | Best for |
45+
| ------------------ | ---------------------------- | ------------ | ------------------------------- | ----------------------------------- |
46+
| `nano-banana` | `google/nano-banana` | $0.05 | 1024×1024, 1216×832, 1024×1792 | Default — fast, cheap, good quality |
47+
| `banana-pro` | `google/nano-banana-pro` | $0.10–$0.15 | up to 4096×4096 | High-res, large format |
48+
| `dalle` | `openai/dall-e-3` | $0.04–$0.08 | 1024×1024, 1792×1024, 1024×1792 | Photorealistic, complex scenes |
49+
| `gpt-image` | `openai/gpt-image-1` | $0.02–$0.04 | 1024×1024, 1536×1024, 1024×1536 | Budget option; supports editing |
50+
| `flux` | `black-forest/flux-1.1-pro` | $0.04 | 1024×1024, 1216×832, 832×1216 | Artistic styles, fewer restrictions |
51+
| `grok-imagine` | `xai/grok-imagine-image` | $0.02 | 1024×1024 | xAI Grok image style |
52+
| `grok-imagine-pro` | `xai/grok-imagine-image-pro` | $0.07 | 1024×1024 | Grok high-quality |
53+
| `cogview` | `zai/cogview-4` | $0.015–$0.02 | 512×512 to 1440×1440 | Cheapest — Zhipu CogView |
5354

5455
**Choosing a model:**
5556

src/index.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1684,10 +1684,7 @@ const plugin: OpenClawPluginDefinition = {
16841684
}
16851685

16861686
// Column width — keep tool names + pricing aligned across groups.
1687-
const toolWidth = Math.max(
1688-
...PARTNER_SERVICES.map((s) => `blockrun_${s.id}`.length),
1689-
28,
1690-
);
1687+
const toolWidth = Math.max(...PARTNER_SERVICES.map((s) => `blockrun_${s.id}`.length), 28);
16911688
const priceWidth = Math.max(
16921689
...PARTNER_SERVICES.map((s) =>
16931690
s.pricing.perUnit === "free" ? 4 : `${s.pricing.perUnit}/${s.pricing.unit}`.length,

src/proxy.tool-forwarding.test.ts

Lines changed: 96 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
22
import type { AddressInfo } from "node:net";
3-
import { afterAll, beforeAll, describe, expect, it } from "vitest";
3+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
44
import { generatePrivateKey } from "viem/accounts";
55

66
import { startProxy, type ProxyHandle } from "./proxy.js";
@@ -10,6 +10,20 @@ describe("tool forwarding", () => {
1010
let proxy: ProxyHandle;
1111
let upstreamUrl = "";
1212
let receivedBody: Record<string, unknown> | null = null;
13+
let upstreamResponse: Record<string, unknown> = {
14+
id: "chatcmpl-tool-forwarding",
15+
object: "chat.completion",
16+
created: Math.floor(Date.now() / 1000),
17+
model: "openai/gpt-4o",
18+
choices: [
19+
{
20+
index: 0,
21+
message: { role: "assistant", content: "ok" },
22+
finish_reason: "stop",
23+
},
24+
],
25+
usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 },
26+
};
1327

1428
beforeAll(async () => {
1529
upstream = createServer(async (req: IncomingMessage, res: ServerResponse) => {
@@ -21,22 +35,7 @@ describe("tool forwarding", () => {
2135
receivedBody = JSON.parse(Buffer.concat(chunks).toString()) as Record<string, unknown>;
2236

2337
res.writeHead(200, { "Content-Type": "application/json" });
24-
res.end(
25-
JSON.stringify({
26-
id: "chatcmpl-tool-forwarding",
27-
object: "chat.completion",
28-
created: Math.floor(Date.now() / 1000),
29-
model: "openai/gpt-4o",
30-
choices: [
31-
{
32-
index: 0,
33-
message: { role: "assistant", content: "ok" },
34-
finish_reason: "stop",
35-
},
36-
],
37-
usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 },
38-
}),
39-
);
38+
res.end(JSON.stringify(upstreamResponse));
4039
});
4140

4241
await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve));
@@ -51,6 +50,23 @@ describe("tool forwarding", () => {
5150
});
5251
}, 10_000);
5352

53+
beforeEach(() => {
54+
upstreamResponse = {
55+
id: "chatcmpl-tool-forwarding",
56+
object: "chat.completion",
57+
created: Math.floor(Date.now() / 1000),
58+
model: "openai/gpt-4o",
59+
choices: [
60+
{
61+
index: 0,
62+
message: { role: "assistant", content: "ok" },
63+
finish_reason: "stop",
64+
},
65+
],
66+
usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 },
67+
};
68+
});
69+
5470
afterAll(async () => {
5571
await proxy?.close();
5672
await new Promise<void>((resolve) => upstream.close(() => resolve()));
@@ -98,4 +114,67 @@ describe("tool forwarding", () => {
98114
expect(parsedTools).toHaveLength(1);
99115
expect(parsedTools[0]?.function?.name).toBe("web_search");
100116
});
117+
118+
it("suppresses assistant content when upstream returns tool_calls", async () => {
119+
upstreamResponse = {
120+
id: "chatcmpl-tool-content",
121+
object: "chat.completion",
122+
created: Math.floor(Date.now() / 1000),
123+
model: "moonshot/kimi-k2.6",
124+
choices: [
125+
{
126+
index: 0,
127+
message: {
128+
role: "assistant",
129+
content:
130+
"The user wants the current time. I should call get_current_time with Chicago.",
131+
tool_calls: [
132+
{
133+
id: "get_current_time:0",
134+
type: "function",
135+
function: {
136+
name: "get_current_time",
137+
arguments: '{"city":"Chicago"}',
138+
},
139+
},
140+
],
141+
},
142+
finish_reason: "tool_calls",
143+
},
144+
],
145+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
146+
};
147+
148+
const res = await fetch(`${proxy.baseUrl}/v1/chat/completions`, {
149+
method: "POST",
150+
headers: { "Content-Type": "application/json" },
151+
body: JSON.stringify({
152+
model: "moonshot/kimi-k2.6",
153+
stream: false,
154+
messages: [{ role: "user", content: "What time is it in Chicago? Use the tool." }],
155+
tools: [
156+
{
157+
type: "function",
158+
function: {
159+
name: "get_current_time",
160+
description: "Get current time",
161+
parameters: { type: "object" },
162+
},
163+
},
164+
],
165+
}),
166+
});
167+
168+
expect(res.status).toBe(200);
169+
const json = (await res.json()) as {
170+
choices?: Array<{
171+
message?: {
172+
content?: string;
173+
tool_calls?: unknown[];
174+
};
175+
}>;
176+
};
177+
expect(json.choices?.[0]?.message?.content).toBe("");
178+
expect(json.choices?.[0]?.message?.tool_calls).toHaveLength(1);
179+
});
101180
});

src/proxy.ts

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2475,7 +2475,11 @@ export async function startProxy(options: ProxyOptions): Promise<ProxyHandle> {
24752475
pollError = `Non-JSON poll response (${pollResp.status}): ${pollText.slice(0, 200)}`;
24762476
break;
24772477
}
2478-
if (pollResp.status === 202 || pollBody.status === "queued" || pollBody.status === "in_progress") {
2478+
if (
2479+
pollResp.status === 202 ||
2480+
pollBody.status === "queued" ||
2481+
pollBody.status === "in_progress"
2482+
) {
24792483
await new Promise((r) => setTimeout(r, pollInterval));
24802484
continue;
24812485
}
@@ -2499,9 +2503,7 @@ export async function startProxy(options: ProxyOptions): Promise<ProxyHandle> {
24992503
}
25002504
if (pollError) {
25012505
res.writeHead(502, { "Content-Type": "application/json" });
2502-
res.end(
2503-
JSON.stringify({ error: "Video generation failed", details: pollError }),
2504-
);
2506+
res.end(JSON.stringify({ error: "Video generation failed", details: pollError }));
25052507
return;
25062508
}
25072509
if (!finalResult.data) {
@@ -2571,9 +2573,7 @@ export async function startProxy(options: ProxyOptions): Promise<ProxyHandle> {
25712573

25722574
// --- Handle paid API paths (/v1/partner/*, /v1/pm/*, /v1/exa/*, /v1/modal/*,
25732575
// /v1/stocks/*, /v1/usstock/*, /v1/crypto/*, /v1/fx/*, /v1/commodity/*) ---
2574-
if (
2575-
req.url?.match(/^\/v1\/(?:partner|pm|exa|modal|stocks|usstock|crypto|fx|commodity)\//)
2576-
) {
2576+
if (req.url?.match(/^\/v1\/(?:partner|pm|exa|modal|stocks|usstock|crypto|fx|commodity)\//)) {
25772577
try {
25782578
await proxyPaidApiRequest(
25792579
req,
@@ -5069,9 +5069,14 @@ async function proxyRequest(
50695069
// Process each choice (usually just one)
50705070
if (rsp.choices && Array.isArray(rsp.choices)) {
50715071
for (const choice of rsp.choices) {
5072+
// Some OpenAI-compatible providers include planning prose in content
5073+
// alongside tool_calls. Tool execution only needs tool_calls, so do
5074+
// not forward that prose to chat channels.
5075+
const toolCalls = choice.message?.tool_calls ?? choice.delta?.tool_calls;
50725076
// Strip thinking tokens (Kimi <|...|> and standard <think> tags)
50735077
const rawContent = choice.message?.content ?? choice.delta?.content ?? "";
5074-
const content = stripThinkingTokens(rawContent);
5078+
const content =
5079+
toolCalls && toolCalls.length > 0 ? "" : stripThinkingTokens(rawContent);
50755080
const role = choice.message?.role ?? choice.delta?.role ?? "assistant";
50765081
const index = choice.index ?? 0;
50775082

@@ -5139,7 +5144,6 @@ async function proxyRequest(
51395144
}
51405145

51415146
// Chunk 2b: tool_calls (forward tool calls from upstream)
5142-
const toolCalls = choice.message?.tool_calls ?? choice.delta?.tool_calls;
51435147
if (toolCalls && toolCalls.length > 0) {
51445148
const toolCallChunk = {
51455149
...baseChunk,
@@ -5295,15 +5299,33 @@ async function proxyRequest(
52955299
if (responseBody.length > 0) {
52965300
try {
52975301
const parsed = JSON.parse(responseBody.toString()) as {
5298-
choices?: Array<{ message?: { content?: string } }>;
5302+
choices?: Array<{
5303+
message?: {
5304+
content?: string;
5305+
tool_calls?: unknown[];
5306+
};
5307+
}>;
52995308
};
5300-
if (parsed.choices?.[0]?.message?.content) {
5301-
const stripped = stripThinkingTokens(parsed.choices[0].message.content);
5302-
if (stripped !== parsed.choices[0].message.content) {
5303-
parsed.choices[0].message.content = stripped;
5304-
responseBody = Buffer.from(JSON.stringify(parsed));
5309+
let changed = false;
5310+
for (const choice of parsed.choices ?? []) {
5311+
const message = choice.message;
5312+
if (!message || typeof message.content !== "string") continue;
5313+
5314+
if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) {
5315+
if (message.content !== "") {
5316+
message.content = "";
5317+
changed = true;
5318+
}
5319+
continue;
5320+
}
5321+
5322+
const stripped = stripThinkingTokens(message.content);
5323+
if (stripped !== message.content) {
5324+
message.content = stripped;
5325+
changed = true;
53055326
}
53065327
}
5328+
if (changed) responseBody = Buffer.from(JSON.stringify(parsed));
53075329
} catch {
53085330
/* not JSON, skip */
53095331
}

src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,9 @@ export type OpenClawPluginDefinition = {
383383
register?: (api: OpenClawPluginApi) => void | Promise<void>;
384384
activate?: (api: OpenClawPluginApi) => void | Promise<void>;
385385
deactivate?: (api: OpenClawPluginApi) => void | Promise<void>;
386+
reload?: {
387+
noopPrefixes?: string[];
388+
};
386389
};
387390

388391
// Command types for registerCommand

0 commit comments

Comments
 (0)