From fbb74bb29e467eb763d027c6c88b747c1865002b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 16 Jan 2026 22:34:58 +0100 Subject: [PATCH] fix(ai): filter empty error assistant messages in transformMessages When 429/500 errors occur during tool execution, empty assistant messages with stopReason='error' get persisted. These break the tool_use -> tool_result chain for Claude/Gemini APIs. Added centralized filtering in transformMessages to skip assistant messages with empty content and no tool calls. Provider-level filters remain for defense-in-depth. --- packages/ai/CHANGELOG.md | 1 + .../ai/src/providers/transform-messages.ts | 8 +++++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 13 ++++++++ .../coding-agent/src/core/agent-session.ts | 8 +++-- packages/coding-agent/src/core/sdk.ts | 6 +++- .../coding-agent/src/core/settings-manager.ts | 10 ++++++ packages/coding-agent/src/core/tools/bash.ts | 8 ++++- packages/coding-agent/src/core/tools/index.ts | 21 +++++++++--- .../test/settings-manager.test.ts | 32 +++++++++++++++++++ packages/coding-agent/test/tools.test.ts | 25 +++++++++++++++ 11 files changed, 125 insertions(+), 8 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 10c2c8de..3f974c48 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed OpenAI-compatible provider feature detection to use `model.provider` in addition to URL, allowing custom base URLs (e.g., proxies) to work correctly with provider-specific settings ([#774](https://github.com/badlogic/pi-mono/issues/774)) - Fixed Bedrock tool call IDs to use only alphanumeric characters, avoiding API errors from invalid characters ([#781](https://github.com/badlogic/pi-mono/pull/781) by [@pjtf93](https://github.com/pjtf93)) +- Fixed empty error assistant messages (from 429/500 errors) breaking the tool_use to tool_result chain by filtering them in `transformMessages` ## [0.47.0] - 2026-01-16 diff --git a/packages/ai/src/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts index e0e5f8d7..14b33545 100644 --- a/packages/ai/src/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -118,6 +118,14 @@ export function transformMessages(messages: Message[], model: existingToolResultIds = new Set(); } + // Skip empty assistant messages (no content and no tool calls) + // This handles error responses (e.g., 429/500) that produced no content + // All providers already filter these in convertMessages, but we do it here + // centrally to prevent issues with the tool_use -> tool_result chain + if (assistantMsg.content.length === 0 && toolCalls.length === 0) { + continue; + } + result.push(msg); } else if (msg.role === "toolResult") { existingToolResultIds.add(msg.toolCallId); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index aeb0b1b9..2b36e180 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added `shellCommandPrefix` setting to prepend commands to every bash execution, enabling alias expansion in non-interactive shells (e.g., `"shellCommandPrefix": "shopt -s expand_aliases"`) ([#790](https://github.com/badlogic/pi-mono/pull/790) by [@richardgill](https://github.com/richardgill)) - Added bash-style argument slicing for prompt templates ([#770](https://github.com/badlogic/pi-mono/pull/770) by [@airtonix](https://github.com/airtonix)) - Extension commands can provide argument auto-completions via `getArgumentCompletions` in `pi.registerCommand()` ([#775](https://github.com/badlogic/pi-mono/pull/775) by [@ribelo](https://github.com/ribelo)) - Bash tool now displays the timeout value in the UI when a timeout is set ([#780](https://github.com/badlogic/pi-mono/pull/780) by [@dannote](https://github.com/dannote)) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 49c41e3e..7ffa9f46 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -116,6 +116,17 @@ For most users, [Git for Windows](https://git-scm.com/download/win) is sufficien } ``` +**Alias expansion:** Pi runs bash in non-interactive mode (`bash -c`), which doesn't expand aliases by default. To enable your shell aliases: + +```json +// ~/.pi/agent/settings.json +{ + "shellCommandPrefix": "shopt -s expand_aliases\neval \"$(grep '^alias ' ~/.zshrc)\"" +} +``` + +Adjust the path (`~/.zshrc`, `~/.bashrc`, etc.) to match your shell config. + ### Terminal Setup Pi uses the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) for reliable modifier key detection. Most modern terminals support this protocol, but some require configuration. @@ -742,6 +753,7 @@ Global `~/.pi/agent/settings.json` stores persistent preferences: "steeringMode": "one-at-a-time", "followUpMode": "one-at-a-time", "shellPath": "C:\\path\\to\\bash.exe", + "shellCommandPrefix": "shopt -s expand_aliases", "hideThinkingBlock": false, "collapseChangelog": false, "compaction": { @@ -778,6 +790,7 @@ Global `~/.pi/agent/settings.json` stores persistent preferences: | `steeringMode` | Steering message delivery: `all` or `one-at-a-time` | `one-at-a-time` | | `followUpMode` | Follow-up message delivery: `all` or `one-at-a-time` | `one-at-a-time` | | `shellPath` | Custom bash path (Windows) | auto-detected | +| `shellCommandPrefix` | Command prefix for bash (e.g., `shopt -s expand_aliases` for alias support) | - | | `hideThinkingBlock` | Hide thinking blocks in output (Ctrl+T to toggle) | `false` | | `collapseChangelog` | Show condensed changelog after update | `false` | | `compaction.enabled` | Enable auto-compaction | `true` | diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 63d6b6db..49ae95fb 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1740,13 +1740,17 @@ export class AgentSession { ): Promise { this._bashAbortController = new AbortController(); + // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support) + const prefix = this.settingsManager.getShellCommandPrefix(); + const resolvedCommand = prefix ? `${prefix}\n${command}` : command; + try { const result = options?.operations - ? await executeBashWithOperations(command, process.cwd(), options.operations, { + ? await executeBashWithOperations(resolvedCommand, process.cwd(), options.operations, { onChunk, signal: this._bashAbortController.signal, }) - : await executeBashCommand(command, { + : await executeBashCommand(resolvedCommand, { onChunk, signal: this._bashAbortController.signal, }); diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index dbe270bd..9d70e097 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -438,8 +438,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} time("discoverContextFiles"); const autoResizeImages = settingsManager.getImageAutoResize(); + const shellCommandPrefix = settingsManager.getShellCommandPrefix(); // Create ALL built-in tools for the registry (extensions can enable any of them) - const allBuiltInToolsMap = createAllTools(cwd, { read: { autoResizeImages } }); + const allBuiltInToolsMap = createAllTools(cwd, { + read: { autoResizeImages }, + bash: { commandPrefix: shellCommandPrefix }, + }); // Determine initially active built-in tools (default: read, bash, edit, write) const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"]; const initialActiveToolNames: ToolName[] = options.tools diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 1aacd627..773a3804 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -61,6 +61,7 @@ export interface Settings { hideThinkingBlock?: boolean; shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) quietStartup?: boolean; + shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) extensions?: string[]; // Array of extension file paths skills?: SkillsSettings; @@ -356,6 +357,15 @@ export class SettingsManager { this.save(); } + getShellCommandPrefix(): string | undefined { + return this.settings.shellCommandPrefix; + } + + setShellCommandPrefix(prefix: string | undefined): void { + this.globalSettings.shellCommandPrefix = prefix; + this.save(); + } + getCollapseChangelog(): boolean { return this.settings.collapseChangelog ?? false; } diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 6d2aa2e8..9a9b1870 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -135,10 +135,13 @@ const defaultBashOperations: BashOperations = { export interface BashToolOptions { /** Custom operations for command execution. Default: local shell */ operations?: BashOperations; + /** Command prefix prepended to every command (e.g., "shopt -s expand_aliases" for alias support) */ + commandPrefix?: string; } export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool { const ops = options?.operations ?? defaultBashOperations; + const commandPrefix = options?.commandPrefix; return { name: "bash", @@ -151,6 +154,9 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo signal?: AbortSignal, onUpdate?, ) => { + // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support) + const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command; + return new Promise((resolve, reject) => { // We'll stream to a temp file if output gets large let tempFilePath: string | undefined; @@ -206,7 +212,7 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo } }; - ops.exec(command, cwd, { onData: handleData, signal, timeout }) + ops.exec(resolvedCommand, cwd, { onData: handleData, signal, timeout }) .then(({ exitCode }) => { // Close temp file stream if (tempFileStream) { diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 462d0410..26328116 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -1,4 +1,10 @@ -export { type BashOperations, type BashToolDetails, type BashToolOptions, bashTool, createBashTool } from "./bash.js"; +export { + type BashOperations, + type BashToolDetails, + type BashToolOptions, + bashTool, + createBashTool, +} from "./bash.js"; export { createEditTool, type EditOperations, type EditToolDetails, type EditToolOptions, editTool } from "./edit.js"; export { createFindTool, type FindOperations, type FindToolDetails, type FindToolOptions, findTool } from "./find.js"; export { createGrepTool, type GrepOperations, type GrepToolDetails, type GrepToolOptions, grepTool } from "./grep.js"; @@ -23,7 +29,7 @@ export { export { createWriteTool, type WriteOperations, type WriteToolOptions, writeTool } from "./write.js"; import type { AgentTool } from "@mariozechner/pi-agent-core"; -import { bashTool, createBashTool } from "./bash.js"; +import { type BashToolOptions, bashTool, createBashTool } from "./bash.js"; import { createEditTool, editTool } from "./edit.js"; import { createFindTool, findTool } from "./find.js"; import { createGrepTool, grepTool } from "./grep.js"; @@ -56,13 +62,20 @@ export type ToolName = keyof typeof allTools; export interface ToolsOptions { /** Options for the read tool */ read?: ReadToolOptions; + /** Options for the bash tool */ + bash?: BashToolOptions; } /** * Create coding tools configured for a specific working directory. */ export function createCodingTools(cwd: string, options?: ToolsOptions): Tool[] { - return [createReadTool(cwd, options?.read), createBashTool(cwd), createEditTool(cwd), createWriteTool(cwd)]; + return [ + createReadTool(cwd, options?.read), + createBashTool(cwd, options?.bash), + createEditTool(cwd), + createWriteTool(cwd), + ]; } /** @@ -78,7 +91,7 @@ export function createReadOnlyTools(cwd: string, options?: ToolsOptions): Tool[] export function createAllTools(cwd: string, options?: ToolsOptions): Record { return { read: createReadTool(cwd, options?.read), - bash: createBashTool(cwd), + bash: createBashTool(cwd, options?.bash), edit: createEditTool(cwd), write: createWriteTool(cwd), grep: createGrepTool(cwd), diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index ad7fb0f3..1598f5a2 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -105,4 +105,36 @@ describe("SettingsManager", () => { expect(savedSettings.defaultThinkingLevel).toBe("high"); }); }); + + describe("shellCommandPrefix", () => { + it("should load shellCommandPrefix from settings", () => { + const settingsPath = join(agentDir, "settings.json"); + writeFileSync(settingsPath, JSON.stringify({ shellCommandPrefix: "shopt -s expand_aliases" })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getShellCommandPrefix()).toBe("shopt -s expand_aliases"); + }); + + it("should return undefined when shellCommandPrefix is not set", () => { + const settingsPath = join(agentDir, "settings.json"); + writeFileSync(settingsPath, JSON.stringify({ theme: "dark" })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getShellCommandPrefix()).toBeUndefined(); + }); + + it("should preserve shellCommandPrefix when saving unrelated settings", () => { + const settingsPath = join(agentDir, "settings.json"); + writeFileSync(settingsPath, JSON.stringify({ shellCommandPrefix: "shopt -s expand_aliases" })); + + const manager = SettingsManager.create(projectDir, agentDir); + manager.setTheme("light"); + + const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); + expect(savedSettings.shellCommandPrefix).toBe("shopt -s expand_aliases"); + expect(savedSettings.theme).toBe("light"); + }); + }); }); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 63d4d93f..851633d2 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -299,6 +299,31 @@ describe("Coding Agent Tools", () => { await expect(bashWithBadShell.execute("test-call-12", { command: "echo test" })).rejects.toThrow(/ENOENT/); }); + + it("should prepend command prefix when configured", async () => { + const bashWithPrefix = createBashTool(testDir, { + commandPrefix: "export TEST_VAR=hello", + }); + + const result = await bashWithPrefix.execute("test-prefix-1", { command: "echo $TEST_VAR" }); + expect(getTextOutput(result).trim()).toBe("hello"); + }); + + it("should include output from both prefix and command", async () => { + const bashWithPrefix = createBashTool(testDir, { + commandPrefix: "echo prefix-output", + }); + + const result = await bashWithPrefix.execute("test-prefix-2", { command: "echo command-output" }); + expect(getTextOutput(result).trim()).toBe("prefix-output\ncommand-output"); + }); + + it("should work without command prefix", async () => { + const bashWithoutPrefix = createBashTool(testDir, {}); + + const result = await bashWithoutPrefix.execute("test-prefix-3", { command: "echo no-prefix" }); + expect(getTextOutput(result).trim()).toBe("no-prefix"); + }); }); describe("grep tool", () => {