mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 22:02:38 +00:00
fix(coding-agent): guard malformed tool args in renderers (fixes #1259)
This commit is contained in:
parent
6c741cbd46
commit
1614e95eca
4 changed files with 84 additions and 54 deletions
|
|
@ -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 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))
|
- 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))
|
- 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
|
## [0.51.6] - 2026-02-04
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -693,6 +693,9 @@
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
padding: 0 var(--line-height);
|
padding: 0 var(--line-height);
|
||||||
}
|
}
|
||||||
|
.tool-error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
/* Images */
|
/* Images */
|
||||||
.message-images {
|
.message-images {
|
||||||
|
|
|
||||||
|
|
@ -540,6 +540,7 @@
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
function shortenPath(p) {
|
function shortenPath(p) {
|
||||||
|
if (typeof p !== 'string') return '';
|
||||||
if (p.startsWith('/Users/')) {
|
if (p.startsWith('/Users/')) {
|
||||||
const parts = p.split('/');
|
const parts = p.split('/');
|
||||||
if (parts.length > 2) return '~' + p.slice(('/Users/' + parts[2]).length);
|
if (parts.length > 2) return '~' + p.slice(('/Users/' + parts[2]).length);
|
||||||
|
|
@ -771,6 +772,13 @@
|
||||||
return text.replace(/\t/g, ' ');
|
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) {
|
function getLanguageFromPath(filePath) {
|
||||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||||
const extToLang = {
|
const extToLang = {
|
||||||
|
|
@ -880,10 +888,13 @@
|
||||||
const args = call.arguments || {};
|
const args = call.arguments || {};
|
||||||
const name = call.name;
|
const name = call.name;
|
||||||
|
|
||||||
|
const invalidArg = '<span class="tool-error">[invalid arg]</span>';
|
||||||
|
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case 'bash': {
|
case 'bash': {
|
||||||
const command = args.command || '';
|
const command = str(args.command);
|
||||||
html += `<div class="tool-command">$ ${escapeHtml(command)}</div>`;
|
const cmdDisplay = command === null ? invalidArg : escapeHtml(command || '...');
|
||||||
|
html += `<div class="tool-command">$ ${cmdDisplay}</div>`;
|
||||||
if (result) {
|
if (result) {
|
||||||
const output = getResultText().trim();
|
const output = getResultText().trim();
|
||||||
if (output) html += formatExpandableOutput(output, 5);
|
if (output) html += formatExpandableOutput(output, 5);
|
||||||
|
|
@ -891,13 +902,12 @@
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'read': {
|
case 'read': {
|
||||||
const filePath = args.file_path || args.path || '';
|
const filePath = str(args.file_path ?? args.path);
|
||||||
const offset = args.offset;
|
const offset = args.offset;
|
||||||
const limit = args.limit;
|
const limit = args.limit;
|
||||||
const lang = getLanguageFromPath(filePath);
|
|
||||||
|
|
||||||
let pathHtml = escapeHtml(shortenPath(filePath));
|
let pathHtml = filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || ''));
|
||||||
if (offset !== undefined || limit !== undefined) {
|
if (filePath !== null && (offset !== undefined || limit !== undefined)) {
|
||||||
const startLine = offset ?? 1;
|
const startLine = offset ?? 1;
|
||||||
const endLine = limit !== undefined ? startLine + limit - 1 : '';
|
const endLine = limit !== undefined ? startLine + limit - 1 : '';
|
||||||
pathHtml += `<span class="line-numbers">:${startLine}${endLine ? '-' + endLine : ''}</span>`;
|
pathHtml += `<span class="line-numbers">:${startLine}${endLine ? '-' + endLine : ''}</span>`;
|
||||||
|
|
@ -907,21 +917,28 @@
|
||||||
if (result) {
|
if (result) {
|
||||||
html += renderResultImages();
|
html += renderResultImages();
|
||||||
const output = getResultText();
|
const output = getResultText();
|
||||||
|
const lang = filePath ? getLanguageFromPath(filePath) : null;
|
||||||
if (output) html += formatExpandableOutput(output, 10, lang);
|
if (output) html += formatExpandableOutput(output, 10, lang);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'write': {
|
case 'write': {
|
||||||
const filePath = args.file_path || args.path || '';
|
const filePath = str(args.file_path ?? args.path);
|
||||||
const content = args.content || '';
|
const content = str(args.content);
|
||||||
const lines = content.split('\n');
|
|
||||||
const lang = getLanguageFromPath(filePath);
|
|
||||||
|
|
||||||
html += `<div class="tool-header"><span class="tool-name">write</span> <span class="tool-path">${escapeHtml(shortenPath(filePath))}</span>`;
|
html += `<div class="tool-header"><span class="tool-name">write</span> <span class="tool-path">${filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || ''))}</span>`;
|
||||||
if (lines.length > 10) html += ` <span class="line-count">(${lines.length} lines)</span>`;
|
if (content !== null && content) {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
if (lines.length > 10) html += ` <span class="line-count">(${lines.length} lines)</span>`;
|
||||||
|
}
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
if (content) html += formatExpandableOutput(content, 10, lang);
|
if (content === null) {
|
||||||
|
html += `<div class="tool-error">[invalid content arg - expected string]</div>`;
|
||||||
|
} else if (content) {
|
||||||
|
const lang = filePath ? getLanguageFromPath(filePath) : null;
|
||||||
|
html += formatExpandableOutput(content, 10, lang);
|
||||||
|
}
|
||||||
if (result) {
|
if (result) {
|
||||||
const output = getResultText().trim();
|
const output = getResultText().trim();
|
||||||
if (output) html += `<div class="tool-output"><div>${escapeHtml(output)}</div></div>`;
|
if (output) html += `<div class="tool-output"><div>${escapeHtml(output)}</div></div>`;
|
||||||
|
|
@ -929,8 +946,8 @@
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'edit': {
|
case 'edit': {
|
||||||
const filePath = args.file_path || args.path || '';
|
const filePath = str(args.file_path ?? args.path);
|
||||||
html += `<div class="tool-header"><span class="tool-name">edit</span> <span class="tool-path">${escapeHtml(shortenPath(filePath))}</span></div>`;
|
html += `<div class="tool-header"><span class="tool-name">edit</span> <span class="tool-path">${filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || ''))}</span></div>`;
|
||||||
|
|
||||||
if (result?.details?.diff) {
|
if (result?.details?.diff) {
|
||||||
const diffLines = result.details.diff.split('\n');
|
const diffLines = result.details.diff.split('\n');
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ const BASH_PREVIEW_LINES = 5;
|
||||||
/**
|
/**
|
||||||
* Convert absolute path to tilde notation if it's in home directory
|
* 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();
|
const home = os.homedir();
|
||||||
if (path.startsWith(home)) {
|
if (path.startsWith(home)) {
|
||||||
return `~${path.slice(home.length)}`;
|
return `~${path.slice(home.length)}`;
|
||||||
|
|
@ -44,6 +45,13 @@ function replaceTabs(text: string): string {
|
||||||
return text.replace(/\t/g, " ");
|
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 {
|
export interface ToolExecutionOptions {
|
||||||
showImages?: boolean; // default: true (only used if terminal supports images)
|
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)
|
* Render bash content using visual line truncation (like bash-execution.ts)
|
||||||
*/
|
*/
|
||||||
private renderBashContent(): void {
|
private renderBashContent(): void {
|
||||||
const command = this.args?.command || "";
|
const command = str(this.args?.command);
|
||||||
const timeout = this.args?.timeout as number | undefined;
|
const timeout = this.args?.timeout as number | undefined;
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
|
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(
|
this.contentBox.addChild(
|
||||||
new Text(
|
new Text(theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix, 0, 0),
|
||||||
theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", "...")}`)) + timeoutSuffix,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.result) {
|
if (this.result) {
|
||||||
|
|
@ -450,13 +456,15 @@ export class ToolExecutionComponent extends Container {
|
||||||
|
|
||||||
private formatToolExecution(): string {
|
private formatToolExecution(): string {
|
||||||
let text = "";
|
let text = "";
|
||||||
|
const invalidArg = theme.fg("error", "[invalid arg]");
|
||||||
|
|
||||||
if (this.toolName === "read") {
|
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 offset = this.args?.offset;
|
||||||
const limit = this.args?.limit;
|
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) {
|
if (offset !== undefined || limit !== undefined) {
|
||||||
const startLine = offset ?? 1;
|
const startLine = offset ?? 1;
|
||||||
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
||||||
|
|
@ -467,8 +475,8 @@ export class ToolExecutionComponent extends Container {
|
||||||
|
|
||||||
if (this.result) {
|
if (this.result) {
|
||||||
const output = this.getTextOutput();
|
const output = this.getTextOutput();
|
||||||
const rawPath = this.args?.file_path || this.args?.path || "";
|
const rawPath = str(this.args?.file_path ?? this.args?.path);
|
||||||
const lang = getLanguageFromPath(rawPath);
|
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
|
||||||
const lines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
|
const lines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
|
||||||
|
|
||||||
const maxLines = this.expanded ? lines.length : 10;
|
const maxLines = this.expanded ? lines.length : 10;
|
||||||
|
|
@ -511,23 +519,21 @@ export class ToolExecutionComponent extends Container {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (this.toolName === "write") {
|
} else if (this.toolName === "write") {
|
||||||
const rawPath = this.args?.file_path || this.args?.path || "";
|
const rawPath = str(this.args?.file_path ?? this.args?.path);
|
||||||
const path = shortenPath(rawPath);
|
const fileContent = str(this.args?.content);
|
||||||
const fileContent = this.args?.content || "";
|
const path = rawPath !== null ? shortenPath(rawPath) : null;
|
||||||
const lang = getLanguageFromPath(rawPath);
|
|
||||||
const lines = fileContent
|
|
||||||
? lang
|
|
||||||
? highlightCode(replaceTabs(fileContent), lang)
|
|
||||||
: fileContent.split("\n")
|
|
||||||
: [];
|
|
||||||
const totalLines = lines.length;
|
|
||||||
|
|
||||||
text =
|
text =
|
||||||
theme.fg("toolTitle", theme.bold("write")) +
|
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 maxLines = this.expanded ? lines.length : 10;
|
||||||
const displayLines = lines.slice(0, maxLines);
|
const displayLines = lines.slice(0, maxLines);
|
||||||
const remaining = lines.length - maxLines;
|
const remaining = lines.length - maxLines;
|
||||||
|
|
@ -552,11 +558,11 @@ export class ToolExecutionComponent extends Container {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (this.toolName === "edit") {
|
} else if (this.toolName === "edit") {
|
||||||
const rawPath = this.args?.file_path || this.args?.path || "";
|
const rawPath = str(this.args?.file_path ?? this.args?.path);
|
||||||
const path = shortenPath(rawPath);
|
const path = rawPath !== null ? shortenPath(rawPath) : null;
|
||||||
|
|
||||||
// Build path display, appending :line if we have diff info
|
// 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 =
|
const firstChangedLine =
|
||||||
(this.editDiffPreview && "firstChangedLine" in this.editDiffPreview
|
(this.editDiffPreview && "firstChangedLine" in this.editDiffPreview
|
||||||
? this.editDiffPreview.firstChangedLine
|
? this.editDiffPreview.firstChangedLine
|
||||||
|
|
@ -578,20 +584,21 @@ export class ToolExecutionComponent extends Container {
|
||||||
// Tool executed successfully - use the diff from result
|
// Tool executed successfully - use the diff from result
|
||||||
// This takes priority over editDiffPreview which may have a stale error
|
// This takes priority over editDiffPreview which may have a stale error
|
||||||
// due to race condition (async preview computed after file was modified)
|
// 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) {
|
} else if (this.editDiffPreview) {
|
||||||
// Use cached diff preview (before tool executes)
|
// Use cached diff preview (before tool executes)
|
||||||
if ("error" in this.editDiffPreview) {
|
if ("error" in this.editDiffPreview) {
|
||||||
text += `\n\n${theme.fg("error", this.editDiffPreview.error)}`;
|
text += `\n\n${theme.fg("error", this.editDiffPreview.error)}`;
|
||||||
} else if (this.editDiffPreview.diff) {
|
} 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") {
|
} 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;
|
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) {
|
if (limit !== undefined) {
|
||||||
text += theme.fg("toolOutput", ` (limit ${limit})`);
|
text += theme.fg("toolOutput", ` (limit ${limit})`);
|
||||||
}
|
}
|
||||||
|
|
@ -624,15 +631,16 @@ export class ToolExecutionComponent extends Container {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (this.toolName === "find") {
|
} else if (this.toolName === "find") {
|
||||||
const pattern = this.args?.pattern || "";
|
const pattern = str(this.args?.pattern);
|
||||||
const path = shortenPath(this.args?.path || ".");
|
const rawPath = str(this.args?.path);
|
||||||
|
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
|
||||||
const limit = this.args?.limit;
|
const limit = this.args?.limit;
|
||||||
|
|
||||||
text =
|
text =
|
||||||
theme.fg("toolTitle", theme.bold("find")) +
|
theme.fg("toolTitle", theme.bold("find")) +
|
||||||
" " +
|
" " +
|
||||||
theme.fg("accent", pattern) +
|
(pattern === null ? invalidArg : theme.fg("accent", pattern || "")) +
|
||||||
theme.fg("toolOutput", ` in ${path}`);
|
theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`);
|
||||||
if (limit !== undefined) {
|
if (limit !== undefined) {
|
||||||
text += theme.fg("toolOutput", ` (limit ${limit})`);
|
text += theme.fg("toolOutput", ` (limit ${limit})`);
|
||||||
}
|
}
|
||||||
|
|
@ -665,16 +673,17 @@ export class ToolExecutionComponent extends Container {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (this.toolName === "grep") {
|
} else if (this.toolName === "grep") {
|
||||||
const pattern = this.args?.pattern || "";
|
const pattern = str(this.args?.pattern);
|
||||||
const path = shortenPath(this.args?.path || ".");
|
const rawPath = str(this.args?.path);
|
||||||
const glob = this.args?.glob;
|
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
|
||||||
|
const glob = str(this.args?.glob);
|
||||||
const limit = this.args?.limit;
|
const limit = this.args?.limit;
|
||||||
|
|
||||||
text =
|
text =
|
||||||
theme.fg("toolTitle", theme.bold("grep")) +
|
theme.fg("toolTitle", theme.bold("grep")) +
|
||||||
" " +
|
" " +
|
||||||
theme.fg("accent", `/${pattern}/`) +
|
(pattern === null ? invalidArg : theme.fg("accent", `/${pattern || ""}/`)) +
|
||||||
theme.fg("toolOutput", ` in ${path}`);
|
theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`);
|
||||||
if (glob) {
|
if (glob) {
|
||||||
text += theme.fg("toolOutput", ` (${glob})`);
|
text += theme.fg("toolOutput", ` (${glob})`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue