feat: chat rendering toolcalls, footer overview

This commit is contained in:
2026-08-09 20:24:11 -04:00
parent cb8a88d0bd
commit 259b1d3b2d
14 changed files with 664 additions and 383 deletions

View File

@@ -19,7 +19,6 @@ import { mkdir, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join } from "node:path";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import { loadAgents, extensionRoot, type AgentDef } from "./agents.js";
import type { ToolCallEntry } from "./phases.js";
export interface AgentTaskOptions {
/** Absolute working directory for the sub-agent. */
@@ -32,8 +31,12 @@ export interface AgentTaskOptions {
allowedTools?: string[];
/** Optional explicit agent definition (skips `loadAgents`). */
agent?: AgentDef;
/** Live callback fired as each tool call starts (ralpi-style stream). */
onToolCall?: (entry: ToolCallEntry) => void;
/**
* Live callback forwarding raw {@link AgentSessionEvent}s from the
* sub-agent session. The `pygienium-stream` forwarder in `index.ts`
* turns tool_execution_start/end + assistant turns into chat messages.
*/
onEvent?: (event: AgentSessionEvent) => void;
}
export interface AgentRunResult {
@@ -43,8 +46,6 @@ export interface AgentRunResult {
text: string;
/** Error message when `ok` is false. */
error?: string;
/** Every tool invocation captured during the run (name + label). */
toolCalls: ToolCallEntry[];
}
export type AgentRunner = (opts: AgentTaskOptions) => Promise<AgentRunResult>;
@@ -82,7 +83,6 @@ export async function defaultAgentRunner(
return {
ok: false,
text: "",
toolCalls: [],
error:
`Unknown agent definition: "${opts.agentName}". ` +
`Available agents: ${names.length > 0 ? names.join(", ") : "(none loaded — check agents/ directories exist)"}. ` +
@@ -123,7 +123,8 @@ export async function defaultAgentRunner(
try {
let text = "";
const toolCalls: ToolCallEntry[] = [];
let stopReason: string | undefined;
let errorMessage: string | undefined;
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
if (
event.type === "message_update" &&
@@ -131,24 +132,58 @@ export async function defaultAgentRunner(
) {
text += event.assistantMessageEvent.delta;
}
if (event.type === "tool_execution_start") {
const entry: ToolCallEntry = {
name: event.toolName,
label: formatToolArg(event.toolName, event.args),
if (event.type === "message_end") {
// Capture the full assistant text from the finalized message —
// models that don't stream text_delta (or truncate) still surface
// their output here. Prefer the streamed text when non-empty.
const message = event.message as {
role?: string;
content?: unknown;
stopReason?: string;
errorMessage?: string;
};
toolCalls.push(entry);
opts.onToolCall?.(entry);
if (message.stopReason) stopReason = message.stopReason;
if (message.errorMessage) errorMessage = message.errorMessage;
if (message.role === "assistant") {
const full = extractAssistantText(message.content).trim();
if (full && !text.trim()) text = full;
}
}
// Forward the stream-driving events to the chat forwarder; it turns
// each into its own `pygienium-stream` message (see index.ts).
if (
event.type === "tool_execution_start" ||
event.type === "tool_execution_end" ||
event.type === "message_end"
) {
opts.onEvent?.(event);
}
});
await session.prompt(opts.task, { expandPromptTemplates: false });
// Ensure the agent has fully settled (tool calls may still be in-flight
// after prompt() resolves; piolium's runner calls this for the same
// reason).
await session.agent.waitForIdle();
unsubscribe();
return { ok: true, text, toolCalls };
// Surface session errors that didn't throw but left no useful output.
// A session ending with stopReason "error" and no text means the model
// call failed silently — treat that as a failed run, not ok:true.
if (errorMessage) {
return { ok: false, text, error: errorMessage };
}
if (!text.trim() && stopReason === "error") {
return {
ok: false,
text,
error: "sub-agent session ended in error with no output.",
};
}
return { ok: true, text };
} catch (err) {
return {
ok: false,
text: "",
error: err instanceof Error ? err.message : String(err),
toolCalls: [],
};
} finally {
try {
@@ -159,6 +194,22 @@ export async function defaultAgentRunner(
}
}
/**
* Extract joined text from an assistant message's content blocks.
* Mirrors piolium's `extractAssistantText`.
*/
function extractAssistantText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.flatMap((c) =>
c && typeof c === "object" && (c as { type?: string }).type === "text"
? [(c as { text?: string }).text ?? ""]
: [],
)
.join("");
}
/**
* Fake agent runner for tests: it understands a tiny instruction protocol
* embedded in the task so a no-op check can produce deterministic findings
@@ -191,57 +242,12 @@ export const fakeAgentRunner: AgentRunner = async (opts) => {
findings.push((echo[1] ?? "").replace(/^["']|["']$/g, ""));
}
}
return { ok: true, text: findings.join("\n"), toolCalls: [] };
return { ok: true, text: findings.join("\n") };
} catch (err) {
return {
ok: false,
text: findings.join("\n"),
error: err instanceof Error ? err.message : String(err),
toolCalls: [],
};
}
};
// ─── Tool-call label formatting (ported from ralpi) ─────────────────────────────
/** Collapse newlines and strip control chars so a tool arg fits one line. */
function sanitizeLabel(s: string): string {
return s
.replace(/\r?\n/g, " ")
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.trim();
}
/** Keep the start and end of a long value, inserting an ellipsis in the middle. */
function truncateMiddle(s: string, maxLen: number): string {
if (s.length <= maxLen) return s;
const half = Math.floor((maxLen - 1) / 2);
return s.slice(0, half) + "…" + s.slice(s.length - half);
}
/**
* Format a tool call's arguments into a short one-line label, by tool name.
* Mirrors ralpi's `formatToolArg` so the chat tree shows the useful argument
* (command for bash, path for read/write/edit, pattern for grep, …).
*/
export function formatToolArg(name: string, args: unknown): string {
const a = (args ?? {}) as Record<string, unknown>;
switch (name) {
case "bash":
return sanitizeLabel(truncateMiddle(String(a.command ?? ""), 70));
case "write":
case "read":
case "edit":
return sanitizeLabel(truncateMiddle(String(a.path ?? ""), 60));
case "grep":
return sanitizeLabel(
`${a.pattern ?? "?"}${truncateMiddle(String(a.path ?? ""), 40)}`,
);
case "find":
return sanitizeLabel(`${a.path ?? "."}${a.glob ?? "*"}`);
case "ls":
return sanitizeLabel(truncateMiddle(String(a.path ?? "."), 60));
default:
return name;
}
}