fix: improve tool summaries

This commit is contained in:
Peter Steinberger
2026-01-23 00:59:44 +00:00
parent 51a9053387
commit 52b6bf04af
10 changed files with 134 additions and 17 deletions

View File

@@ -30,6 +30,7 @@ Docs: https://docs.clawd.bot
- Agents: surface concrete API error details instead of generic AI service errors. - Agents: surface concrete API error details instead of generic AI service errors.
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj. - Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider. - Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
- Agents: make tool summaries more readable and only show optional params when set.
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x. - Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status. - macOS: prefer linked channels in gateway summary to avoid false “not linked” status.

View File

@@ -51,7 +51,7 @@ You can tune console verbosity independently via:
## Tool summary redaction ## Tool summary redaction
Verbose tool summaries (e.g. `🛠️ exec: ...`) can mask sensitive tokens before they hit the Verbose tool summaries (e.g. `🛠️ Exec: ...`) can mask sensitive tokens before they hit the
console stream. This is **tools-only** and does not alter file logs. console stream. This is **tools-only** and does not alter file logs.
- `logging.redactSensitive`: `off` | `tools` (default: `tools`) - `logging.redactSensitive`: `off` | `tools` (default: `tools`)

View File

@@ -127,7 +127,7 @@ describe("buildEmbeddedRunPayloads", () => {
expect(payloads).toHaveLength(1); expect(payloads).toHaveLength(1);
expect(payloads[0]?.isError).toBe(true); expect(payloads[0]?.isError).toBe(true);
expect(payloads[0]?.text).toContain("browser"); expect(payloads[0]?.text).toContain("Browser");
expect(payloads[0]?.text).toContain("tab not found"); expect(payloads[0]?.text).toContain("tab not found");
}); });

View File

@@ -44,7 +44,7 @@ describe("subscribeEmbeddedPiSession", () => {
expect(onToolResult).toHaveBeenCalledTimes(1); expect(onToolResult).toHaveBeenCalledTimes(1);
const payload = onToolResult.mock.calls[0][0]; const payload = onToolResult.mock.calls[0][0];
expect(payload.text).toContain("🖼️"); expect(payload.text).toContain("🖼️");
expect(payload.text).toContain("canvas"); expect(payload.text).toContain("Canvas");
expect(payload.text).toContain("A2UI push"); expect(payload.text).toContain("A2UI push");
expect(payload.text).toContain("/tmp/a2ui.jsonl"); expect(payload.text).toContain("/tmp/a2ui.jsonl");
}); });

View File

@@ -166,7 +166,7 @@ describe("subscribeEmbeddedPiSession", () => {
expect(onToolResult).toHaveBeenCalledTimes(1); expect(onToolResult).toHaveBeenCalledTimes(1);
const payload = onToolResult.mock.calls[0][0]; const payload = onToolResult.mock.calls[0][0];
expect(payload.text).toContain("🌐"); expect(payload.text).toContain("🌐");
expect(payload.text).toContain("browser"); expect(payload.text).toContain("Browser");
expect(payload.text).toContain("snapshot"); expect(payload.text).toContain("snapshot");
expect(payload.text).toContain("https://example.com"); expect(payload.text).toContain("https://example.com");
}); });
@@ -200,7 +200,7 @@ describe("subscribeEmbeddedPiSession", () => {
expect(onToolResult).toHaveBeenCalledTimes(1); expect(onToolResult).toHaveBeenCalledTimes(1);
const summary = onToolResult.mock.calls[0][0]; const summary = onToolResult.mock.calls[0][0];
expect(summary.text).toContain("exec"); expect(summary.text).toContain("Exec");
expect(summary.text).toContain("pty"); expect(summary.text).toContain("pty");
handler?.({ handler?.({

View File

@@ -31,7 +31,8 @@ const subagentRuns = new Map<string, SubagentRunRecord>();
let sweeper: NodeJS.Timeout | null = null; let sweeper: NodeJS.Timeout | null = null;
let listenerStarted = false; let listenerStarted = false;
let listenerStop: (() => void) | null = null; let listenerStop: (() => void) | null = null;
let restoreAttempted = false; // Use var to avoid TDZ when init runs across circular imports during bootstrap.
var restoreAttempted = false;
function persistSubagentRuns() { function persistSubagentRuns() {
try { try {

View File

@@ -256,17 +256,26 @@
"sessions_history": { "sessions_history": {
"emoji": "🧾", "emoji": "🧾",
"title": "Session History", "title": "Session History",
"detailKeys": ["sessionKey", "limit"] "detailKeys": ["sessionKey", "limit", "includeTools"]
}, },
"sessions_send": { "sessions_send": {
"emoji": "📨", "emoji": "📨",
"title": "Session Send", "title": "Session Send",
"detailKeys": ["sessionKey", "timeoutSeconds"] "detailKeys": ["label", "sessionKey", "agentId", "timeoutSeconds"]
}, },
"sessions_spawn": { "sessions_spawn": {
"emoji": "🧑‍🔧", "emoji": "🧑‍🔧",
"title": "Sub-agent", "title": "Sub-agent",
"detailKeys": ["label", "agentId", "thinking", "runTimeoutSeconds", "cleanup"] "detailKeys": [
"label",
"task",
"agentId",
"model",
"thinking",
"runTimeoutSeconds",
"cleanup",
"timeoutSeconds"
]
}, },
"session_status": { "session_status": {
"emoji": "📊", "emoji": "📊",

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
describe("tool display details", () => {
it("skips zero/false values for optional detail fields", () => {
const detail = formatToolDetail(
resolveToolDisplay({
name: "sessions_spawn",
args: {
task: "double-message-bug-gpt",
label: 0,
runTimeoutSeconds: 0,
timeoutSeconds: 0,
},
}),
);
expect(detail).toBe("double-message-bug-gpt");
});
it("includes only truthy boolean details", () => {
const detail = formatToolDetail(
resolveToolDisplay({
name: "message",
args: {
action: "react",
provider: "discord",
to: "chan-1",
remove: false,
},
}),
);
expect(detail).toContain("provider discord");
expect(detail).toContain("to chan-1");
expect(detail).not.toContain("remove");
});
it("keeps positive numbers and true booleans", () => {
const detail = formatToolDetail(
resolveToolDisplay({
name: "sessions_history",
args: {
sessionKey: "agent:main:main",
limit: 20,
includeTools: true,
},
}),
);
expect(detail).toContain("session agent:main:main");
expect(detail).toContain("limit 20");
expect(detail).toContain("tools true");
});
});

View File

@@ -33,6 +33,25 @@ export type ToolDisplay = {
const TOOL_DISPLAY_CONFIG = TOOL_DISPLAY_JSON as ToolDisplayConfig; const TOOL_DISPLAY_CONFIG = TOOL_DISPLAY_JSON as ToolDisplayConfig;
const FALLBACK = TOOL_DISPLAY_CONFIG.fallback ?? { emoji: "🧩" }; const FALLBACK = TOOL_DISPLAY_CONFIG.fallback ?? { emoji: "🧩" };
const TOOL_MAP = TOOL_DISPLAY_CONFIG.tools ?? {}; const TOOL_MAP = TOOL_DISPLAY_CONFIG.tools ?? {};
const DETAIL_LABEL_OVERRIDES: Record<string, string> = {
agentId: "agent",
sessionKey: "session",
targetId: "target",
targetUrl: "url",
nodeId: "node",
requestId: "request",
messageId: "message",
threadId: "thread",
channelId: "channel",
guildId: "guild",
userId: "user",
runTimeoutSeconds: "timeout",
timeoutSeconds: "timeout",
includeTools: "tools",
pollQuestion: "poll",
maxChars: "max chars",
};
const MAX_DETAIL_ENTRIES = 8;
function normalizeToolName(name?: string): string { function normalizeToolName(name?: string): string {
return (name ?? "tool").trim(); return (name ?? "tool").trim();
@@ -66,7 +85,11 @@ function coerceDisplayValue(value: unknown): string | undefined {
if (!firstLine) return undefined; if (!firstLine) return undefined;
return firstLine.length > 160 ? `${firstLine.slice(0, 157)}` : firstLine; return firstLine.length > 160 ? `${firstLine.slice(0, 157)}` : firstLine;
} }
if (typeof value === "number" || typeof value === "boolean") { if (typeof value === "boolean") {
return value ? "true" : undefined;
}
if (typeof value === "number") {
if (!Number.isFinite(value) || value === 0) return undefined;
return String(value); return String(value);
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
@@ -92,13 +115,40 @@ function lookupValueByPath(args: unknown, path: string): unknown {
return current; return current;
} }
function formatDetailKey(raw: string): string {
const segments = raw.split(".").filter(Boolean);
const last = segments.at(-1) ?? raw;
const override = DETAIL_LABEL_OVERRIDES[last];
if (override) return override;
const cleaned = last.replace(/_/g, " ").replace(/-/g, " ");
const spaced = cleaned.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
return spaced.trim().toLowerCase() || last.toLowerCase();
}
function resolveDetailFromKeys(args: unknown, keys: string[]): string | undefined { function resolveDetailFromKeys(args: unknown, keys: string[]): string | undefined {
const entries: Array<{ label: string; value: string }> = [];
for (const key of keys) { for (const key of keys) {
const value = lookupValueByPath(args, key); const value = lookupValueByPath(args, key);
const display = coerceDisplayValue(value); const display = coerceDisplayValue(value);
if (display) return display; if (!display) continue;
entries.push({ label: formatDetailKey(key), value: display });
} }
return undefined; if (entries.length === 0) return undefined;
if (entries.length === 1) return entries[0].value;
const seen = new Set<string>();
const unique: Array<{ label: string; value: string }> = [];
for (const entry of entries) {
const token = `${entry.label}:${entry.value}`;
if (seen.has(token)) continue;
seen.add(token);
unique.push(entry);
}
if (unique.length === 0) return undefined;
return unique
.slice(0, MAX_DETAIL_ENTRIES)
.map((entry) => `${entry.label} ${entry.value}`)
.join(" · ");
} }
function resolveReadDetail(args: unknown): string | undefined { function resolveReadDetail(args: unknown): string | undefined {
@@ -139,7 +189,7 @@ export function resolveToolDisplay(params: {
const spec = TOOL_MAP[key]; const spec = TOOL_MAP[key];
const emoji = spec?.emoji ?? FALLBACK.emoji ?? "🧩"; const emoji = spec?.emoji ?? FALLBACK.emoji ?? "🧩";
const title = spec?.title ?? defaultTitle(name); const title = spec?.title ?? defaultTitle(name);
const label = spec?.label ?? name; const label = spec?.label ?? title;
const actionRaw = const actionRaw =
params.args && typeof params.args === "object" params.args && typeof params.args === "object"
? ((params.args as Record<string, unknown>).action as string | undefined) ? ((params.args as Record<string, unknown>).action as string | undefined)

View File

@@ -30,7 +30,7 @@ describe("tool meta formatting", () => {
"note", "note",
"a→b", "a→b",
]); ]);
expect(out).toMatch(/^🧩 fs/); expect(out).toMatch(/^🧩 Fs/);
expect(out).toContain("~/dir/{a.txt, b.txt}"); expect(out).toContain("~/dir/{a.txt, b.txt}");
expect(out).toContain("note"); expect(out).toContain("note");
expect(out).toContain("a→b"); expect(out).toContain("a→b");
@@ -47,12 +47,12 @@ describe("tool meta formatting", () => {
const out = formatToolAggregate("exec", ["cd /Users/test/dir && gemini 2>&1 · elevated"], { const out = formatToolAggregate("exec", ["cd /Users/test/dir && gemini 2>&1 · elevated"], {
markdown: true, markdown: true,
}); });
expect(out).toBe("🛠️ exec: elevated · `cd ~/dir && gemini 2>&1`"); expect(out).toBe("🛠️ Exec: elevated · `cd ~/dir && gemini 2>&1`");
}); });
it("formats prefixes with default labels", () => { it("formats prefixes with default labels", () => {
vi.stubEnv("HOME", "/Users/test"); vi.stubEnv("HOME", "/Users/test");
expect(formatToolPrefix(undefined, undefined)).toBe("🧩 tool"); expect(formatToolPrefix(undefined, undefined)).toBe("🧩 Tool");
expect(formatToolPrefix("x", "/Users/test/a.txt")).toBe("🧩 x: ~/a.txt"); expect(formatToolPrefix("x", "/Users/test/a.txt")).toBe("🧩 X: ~/a.txt");
}); });
}); });