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

@ -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);
}
}