feat: chat rendering toolcalls, footer overview
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user