diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d57df01d..ca10fbde 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ - Fixed HTML export losing indentation in ANSI-rendered tool output (e.g. JSON code blocks in custom tool results) ([#1269](https://github.com/badlogic/pi-mono/pull/1269) by [@aliou](https://github.com/aliou)) - Fixed images being silently dropped when `prompt()` is called with both `images` and `streamingBehavior` during streaming. `steer()`, `followUp()`, and the corresponding RPC commands now accept optional images. ([#1271](https://github.com/badlogic/pi-mono/pull/1271) by [@aliou](https://github.com/aliou)) - CLI `--help`, `--version`, `--list-models`, and `--export` now exit even if extensions keep the event loop alive ([#1285](https://github.com/badlogic/pi-mono/pull/1285) by [@ferologics](https://github.com/ferologics)) +- Fixed crash when models send malformed tool arguments (objects instead of strings) ([#1259](https://github.com/badlogic/pi-mono/issues/1259)) ## [0.51.6] - 2026-02-04 diff --git a/packages/coding-agent/src/core/export-html/template.css b/packages/coding-agent/src/core/export-html/template.css index bbf8e326..fbcec72a 100644 --- a/packages/coding-agent/src/core/export-html/template.css +++ b/packages/coding-agent/src/core/export-html/template.css @@ -693,6 +693,9 @@ color: var(--error); padding: 0 var(--line-height); } + .tool-error { + color: var(--error); + } /* Images */ .message-images { diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index 7d5edae0..551e34f5 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -540,6 +540,7 @@ // ============================================================ function shortenPath(p) { + if (typeof p !== 'string') return ''; if (p.startsWith('/Users/')) { const parts = p.split('/'); if (parts.length > 2) return '~' + p.slice(('/Users/' + parts[2]).length); @@ -771,6 +772,13 @@ return text.replace(/\t/g, ' '); } + /** Safely coerce value to string for display. Returns null if invalid type. */ + function str(value) { + if (typeof value === 'string') return value; + if (value == null) return ''; + return null; + } + function getLanguageFromPath(filePath) { const ext = filePath.split('.').pop()?.toLowerCase(); const extToLang = { @@ -880,10 +888,13 @@ const args = call.arguments || {}; const name = call.name; + const invalidArg = '[invalid arg]'; + switch (name) { case 'bash': { - const command = args.command || ''; - html += `
$ ${escapeHtml(command)}
`; + const command = str(args.command); + const cmdDisplay = command === null ? invalidArg : escapeHtml(command || '...'); + html += `
$ ${cmdDisplay}
`; if (result) { const output = getResultText().trim(); if (output) html += formatExpandableOutput(output, 5); @@ -891,13 +902,12 @@ break; } case 'read': { - const filePath = args.file_path || args.path || ''; + const filePath = str(args.file_path ?? args.path); const offset = args.offset; const limit = args.limit; - const lang = getLanguageFromPath(filePath); - let pathHtml = escapeHtml(shortenPath(filePath)); - if (offset !== undefined || limit !== undefined) { + let pathHtml = filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || '')); + if (filePath !== null && (offset !== undefined || limit !== undefined)) { const startLine = offset ?? 1; const endLine = limit !== undefined ? startLine + limit - 1 : ''; pathHtml += `:${startLine}${endLine ? '-' + endLine : ''}`; @@ -907,21 +917,28 @@ if (result) { html += renderResultImages(); const output = getResultText(); + const lang = filePath ? getLanguageFromPath(filePath) : null; if (output) html += formatExpandableOutput(output, 10, lang); } break; } case 'write': { - const filePath = args.file_path || args.path || ''; - const content = args.content || ''; - const lines = content.split('\n'); - const lang = getLanguageFromPath(filePath); + const filePath = str(args.file_path ?? args.path); + const content = str(args.content); - html += `
write ${escapeHtml(shortenPath(filePath))}`; - if (lines.length > 10) html += ` (${lines.length} lines)`; + html += `
write ${filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || ''))}`; + if (content !== null && content) { + const lines = content.split('\n'); + if (lines.length > 10) html += ` (${lines.length} lines)`; + } html += '
'; - if (content) html += formatExpandableOutput(content, 10, lang); + if (content === null) { + html += `
[invalid content arg - expected string]
`; + } else if (content) { + const lang = filePath ? getLanguageFromPath(filePath) : null; + html += formatExpandableOutput(content, 10, lang); + } if (result) { const output = getResultText().trim(); if (output) html += `
${escapeHtml(output)}
`; @@ -929,8 +946,8 @@ break; } case 'edit': { - const filePath = args.file_path || args.path || ''; - html += `
edit ${escapeHtml(shortenPath(filePath))}
`; + const filePath = str(args.file_path ?? args.path); + html += `
edit ${filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || ''))}
`; if (result?.details?.diff) { const diffLines = result.details.diff.split('\n'); diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 35bad0e6..6678e0d1 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -29,7 +29,8 @@ const BASH_PREVIEW_LINES = 5; /** * Convert absolute path to tilde notation if it's in home directory */ -function shortenPath(path: string): string { +function shortenPath(path: unknown): string { + if (typeof path !== "string") return ""; const home = os.homedir(); if (path.startsWith(home)) { return `~${path.slice(home.length)}`; @@ -44,6 +45,13 @@ function replaceTabs(text: string): string { return text.replace(/\t/g, " "); } +/** Safely coerce value to string for display. Returns null if invalid type. */ +function str(value: unknown): string | null { + if (typeof value === "string") return value; + if (value == null) return ""; + return null; // Invalid type +} + export interface ToolExecutionOptions { showImages?: boolean; // default: true (only used if terminal supports images) } @@ -341,17 +349,15 @@ export class ToolExecutionComponent extends Container { * Render bash content using visual line truncation (like bash-execution.ts) */ private renderBashContent(): void { - const command = this.args?.command || ""; + const command = str(this.args?.command); const timeout = this.args?.timeout as number | undefined; // Header const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : ""; + const commandDisplay = + command === null ? theme.fg("error", "[invalid arg]") : command ? command : theme.fg("toolOutput", "..."); this.contentBox.addChild( - new Text( - theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", "...")}`)) + timeoutSuffix, - 0, - 0, - ), + new Text(theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix, 0, 0), ); if (this.result) { @@ -450,13 +456,15 @@ export class ToolExecutionComponent extends Container { private formatToolExecution(): string { let text = ""; + const invalidArg = theme.fg("error", "[invalid arg]"); if (this.toolName === "read") { - const path = shortenPath(this.args?.file_path || this.args?.path || ""); + const rawPath = str(this.args?.file_path ?? this.args?.path); + const path = rawPath !== null ? shortenPath(rawPath) : null; const offset = this.args?.offset; const limit = this.args?.limit; - let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); + let pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); if (offset !== undefined || limit !== undefined) { const startLine = offset ?? 1; const endLine = limit !== undefined ? startLine + limit - 1 : ""; @@ -467,8 +475,8 @@ export class ToolExecutionComponent extends Container { if (this.result) { const output = this.getTextOutput(); - const rawPath = this.args?.file_path || this.args?.path || ""; - const lang = getLanguageFromPath(rawPath); + const rawPath = str(this.args?.file_path ?? this.args?.path); + const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; const lines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n"); const maxLines = this.expanded ? lines.length : 10; @@ -511,23 +519,21 @@ export class ToolExecutionComponent extends Container { } } } else if (this.toolName === "write") { - const rawPath = this.args?.file_path || this.args?.path || ""; - const path = shortenPath(rawPath); - const fileContent = this.args?.content || ""; - const lang = getLanguageFromPath(rawPath); - const lines = fileContent - ? lang - ? highlightCode(replaceTabs(fileContent), lang) - : fileContent.split("\n") - : []; - const totalLines = lines.length; + const rawPath = str(this.args?.file_path ?? this.args?.path); + const fileContent = str(this.args?.content); + const path = rawPath !== null ? shortenPath(rawPath) : null; text = theme.fg("toolTitle", theme.bold("write")) + " " + - (path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")); + (path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")); - if (fileContent) { + if (fileContent === null) { + text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`; + } else if (fileContent) { + const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; + const lines = lang ? highlightCode(replaceTabs(fileContent), lang) : fileContent.split("\n"); + const totalLines = lines.length; const maxLines = this.expanded ? lines.length : 10; const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; @@ -552,11 +558,11 @@ export class ToolExecutionComponent extends Container { } } } else if (this.toolName === "edit") { - const rawPath = this.args?.file_path || this.args?.path || ""; - const path = shortenPath(rawPath); + const rawPath = str(this.args?.file_path ?? this.args?.path); + const path = rawPath !== null ? shortenPath(rawPath) : null; // Build path display, appending :line if we have diff info - let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); + let pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); const firstChangedLine = (this.editDiffPreview && "firstChangedLine" in this.editDiffPreview ? this.editDiffPreview.firstChangedLine @@ -578,20 +584,21 @@ export class ToolExecutionComponent extends Container { // Tool executed successfully - use the diff from result // This takes priority over editDiffPreview which may have a stale error // due to race condition (async preview computed after file was modified) - text += `\n\n${renderDiff(this.result.details.diff, { filePath: rawPath })}`; + text += `\n\n${renderDiff(this.result.details.diff, { filePath: rawPath ?? undefined })}`; } else if (this.editDiffPreview) { // Use cached diff preview (before tool executes) if ("error" in this.editDiffPreview) { text += `\n\n${theme.fg("error", this.editDiffPreview.error)}`; } else if (this.editDiffPreview.diff) { - text += `\n\n${renderDiff(this.editDiffPreview.diff, { filePath: rawPath })}`; + text += `\n\n${renderDiff(this.editDiffPreview.diff, { filePath: rawPath ?? undefined })}`; } } } else if (this.toolName === "ls") { - const path = shortenPath(this.args?.path || "."); + const rawPath = str(this.args?.path); + const path = rawPath !== null ? shortenPath(rawPath || ".") : null; const limit = this.args?.limit; - text = `${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", path)}`; + text = `${theme.fg("toolTitle", theme.bold("ls"))} ${path === null ? invalidArg : theme.fg("accent", path)}`; if (limit !== undefined) { text += theme.fg("toolOutput", ` (limit ${limit})`); } @@ -624,15 +631,16 @@ export class ToolExecutionComponent extends Container { } } } else if (this.toolName === "find") { - const pattern = this.args?.pattern || ""; - const path = shortenPath(this.args?.path || "."); + const pattern = str(this.args?.pattern); + const rawPath = str(this.args?.path); + const path = rawPath !== null ? shortenPath(rawPath || ".") : null; const limit = this.args?.limit; text = theme.fg("toolTitle", theme.bold("find")) + " " + - theme.fg("accent", pattern) + - theme.fg("toolOutput", ` in ${path}`); + (pattern === null ? invalidArg : theme.fg("accent", pattern || "")) + + theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`); if (limit !== undefined) { text += theme.fg("toolOutput", ` (limit ${limit})`); } @@ -665,16 +673,17 @@ export class ToolExecutionComponent extends Container { } } } else if (this.toolName === "grep") { - const pattern = this.args?.pattern || ""; - const path = shortenPath(this.args?.path || "."); - const glob = this.args?.glob; + const pattern = str(this.args?.pattern); + const rawPath = str(this.args?.path); + const path = rawPath !== null ? shortenPath(rawPath || ".") : null; + const glob = str(this.args?.glob); const limit = this.args?.limit; text = theme.fg("toolTitle", theme.bold("grep")) + " " + - theme.fg("accent", `/${pattern}/`) + - theme.fg("toolOutput", ` in ${path}`); + (pattern === null ? invalidArg : theme.fg("accent", `/${pattern || ""}/`)) + + theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`); if (glob) { text += theme.fg("toolOutput", ` (${glob})`); }