Add tool result streaming

- Add AgentToolUpdateCallback type and optional onUpdate callback to AgentTool.execute()
- Add tool_execution_update event with toolCallId, toolName, args, partialResult
- Normalize tool_execution_end to always use AgentToolResult (no more string fallback)
- Bash tool streams truncated rolling buffer output during execution
- ToolExecutionComponent shows last N lines when collapsed (not first N)
- Interactive mode handles tool_execution_update events
- Update RPC docs and ai/agent READMEs

fixes #44
This commit is contained in:
Mario Zechner 2025-12-16 14:53:17 +01:00
parent 8319628bc3
commit 7ac832586f
12 changed files with 362 additions and 51 deletions

View file

@ -2,8 +2,14 @@
## [Unreleased]
### Added
- **Streaming bash output**: Bash tool now streams output in real-time during execution. The TUI displays live progress with the last 5 lines visible (expandable with ctrl+o). ([#44](https://github.com/badlogic/pi-mono/issues/44))
### Changed
- **Tool output display**: When collapsed, tool output now shows the last N lines instead of the first N lines, making streaming output more useful.
- Updated `@mariozechner/pi-ai` with X-Initiator header support for GitHub Copilot, ensuring agent calls are not deducted from quota. ([#200](https://github.com/badlogic/pi-mono/pull/200) by [@kim0](https://github.com/kim0))
### Fixed

View file

@ -553,6 +553,7 @@ Events are streamed to stdout as JSON lines during agent operation. Events do NO
| `message_update` | Streaming update (text/thinking/toolcall deltas) |
| `message_end` | Message completes |
| `tool_execution_start` | Tool begins execution |
| `tool_execution_update` | Tool execution progress (streaming output) |
| `tool_execution_end` | Tool completes |
| `auto_compaction_start` | Auto-compaction begins |
| `auto_compaction_end` | Auto-compaction completes |
@ -645,9 +646,9 @@ Example streaming a text response:
{"type":"message_update","message":{...},"assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Hello world","partial":{...}}}
```
### tool_execution_start / tool_execution_end
### tool_execution_start / tool_execution_update / tool_execution_end
Emitted when a tool begins and completes execution.
Emitted when a tool begins, streams progress, and completes execution.
```json
{
@ -658,6 +659,23 @@ Emitted when a tool begins and completes execution.
}
```
During execution, `tool_execution_update` events stream partial results (e.g., bash output as it arrives):
```json
{
"type": "tool_execution_update",
"toolCallId": "call_abc123",
"toolName": "bash",
"args": {"command": "ls -la"},
"partialResult": {
"content": [{"type": "text", "text": "partial output so far..."}],
"details": {"truncation": null, "fullOutputPath": null}
}
}
```
When complete:
```json
{
"type": "tool_execution_end",
@ -671,7 +689,7 @@ Emitted when a tool begins and completes execution.
}
```
Use `toolCallId` to correlate `tool_execution_start` with `tool_execution_end`.
Use `toolCallId` to correlate events. The `partialResult` in `tool_execution_update` contains the accumulated output so far (not just the delta), allowing clients to simply replace their display on each update.
### auto_compaction_start / auto_compaction_end

View file

@ -35,6 +35,7 @@ export const bashTool: AgentTool<typeof bashSchema> = {
_toolCallId: string,
{ command, timeout }: { command: string; timeout?: number },
signal?: AbortSignal,
onUpdate?,
) => {
return new Promise((resolve, reject) => {
const { shell, args } = getShellConfig();
@ -92,6 +93,20 @@ export const bashTool: AgentTool<typeof bashSchema> = {
const removed = chunks.shift()!;
chunksBytes -= removed.length;
}
// Stream partial output to callback (truncated rolling buffer)
if (onUpdate) {
const fullBuffer = Buffer.concat(chunks);
const fullText = fullBuffer.toString("utf-8");
const truncation = truncateTail(fullText);
onUpdate({
content: [{ type: "text", text: truncation.content || "" }],
details: {
truncation: truncation.truncated ? truncation : undefined,
fullOutputPath: tempFilePath,
},
});
}
};
// Collect stdout and stderr together

View file

@ -44,6 +44,7 @@ export class ToolExecutionComponent extends Container {
private args: any;
private expanded = false;
private showImages: boolean;
private isPartial = false;
private result?: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
isError: boolean;
@ -66,12 +67,16 @@ export class ToolExecutionComponent extends Container {
this.updateDisplay();
}
updateResult(result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: any;
isError: boolean;
}): void {
updateResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: any;
isError: boolean;
},
isPartial = false,
): void {
this.result = result;
this.isPartial = isPartial;
this.updateDisplay();
}
@ -86,11 +91,11 @@ export class ToolExecutionComponent extends Container {
}
private updateDisplay(): void {
const bgFn = this.result
? this.result.isError
const bgFn = this.isPartial
? (text: string) => theme.bg("toolPendingBg", text)
: this.result?.isError
? (text: string) => theme.bg("toolErrorBg", text)
: (text: string) => theme.bg("toolSuccessBg", text)
: (text: string) => theme.bg("toolPendingBg", text);
: (text: string) => theme.bg("toolSuccessBg", text);
this.contentText.setCustomBgFn(bgFn);
this.contentText.setText(this.formatToolExecution());
@ -164,13 +169,15 @@ export class ToolExecutionComponent extends Container {
if (output) {
const lines = output.split("\n");
const maxLines = this.expanded ? lines.length : 5;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
const skipped = Math.max(0, lines.length - maxLines);
const displayLines = lines.slice(-maxLines);
text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n");
if (remaining > 0) {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
if (skipped > 0) {
text += theme.fg("toolOutput", `\n\n... (${skipped} earlier lines)`);
}
text +=
(skipped > 0 ? "\n" : "\n\n") +
displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n");
}
// Show truncation warning at the bottom (outside collapsed area)

View file

@ -755,18 +755,19 @@ export class InteractiveMode {
break;
}
case "tool_execution_update": {
const component = this.pendingTools.get(event.toolCallId);
if (component) {
component.updateResult({ ...event.partialResult, isError: false }, true);
this.ui.requestRender();
}
break;
}
case "tool_execution_end": {
const component = this.pendingTools.get(event.toolCallId);
if (component) {
const resultData =
typeof event.result === "string"
? {
content: [{ type: "text" as const, text: event.result }],
details: undefined,
isError: event.isError,
}
: { content: event.result.content, details: event.result.details, isError: event.isError };
component.updateResult(resultData);
component.updateResult({ ...event.result, isError: event.isError });
this.pendingTools.delete(event.toolCallId);
this.ui.requestRender();
}
@ -993,11 +994,7 @@ export class InteractiveMode {
} else if (message.role === "toolResult") {
const component = this.pendingTools.get(message.toolCallId);
if (component) {
component.updateResult({
content: message.content,
details: message.details,
isError: message.isError,
});
component.updateResult(message);
this.pendingTools.delete(message.toolCallId);
}
}