feat: extend verbose tool feedback

This commit is contained in:
Peter Steinberger
2026-01-17 05:33:27 +00:00
parent 4d314db750
commit 99dd428862
31 changed files with 208 additions and 34 deletions

View File

@@ -213,6 +213,7 @@ export async function runEmbeddedPiAgent(
runId: params.runId,
abortSignal: params.abortSignal,
shouldEmitToolResult: params.shouldEmitToolResult,
shouldEmitToolOutput: params.shouldEmitToolOutput,
onPartialReply: params.onPartialReply,
onAssistantMessageStart: params.onAssistantMessageStart,
onBlockReply: params.onBlockReply,

View File

@@ -366,6 +366,7 @@ export async function runEmbeddedAttempt(
verboseLevel: params.verboseLevel,
reasoningMode: params.reasoningLevel ?? "off",
shouldEmitToolResult: params.shouldEmitToolResult,
shouldEmitToolOutput: params.shouldEmitToolOutput,
onToolResult: params.onToolResult,
onReasoningStream: params.onReasoningStream,
onBlockReply: params.onBlockReply,

View File

@@ -38,6 +38,7 @@ export type RunEmbeddedPiAgentParams = {
runId: string;
abortSignal?: AbortSignal;
shouldEmitToolResult?: () => boolean;
shouldEmitToolOutput?: () => boolean;
onPartialReply?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onAssistantMessageStart?: () => void | Promise<void>;
onBlockReply?: (payload: {

View File

@@ -68,7 +68,9 @@ export function buildEmbeddedRunPayloads(params: {
if (errorText) replyItems.push({ text: errorText, isError: true });
const inlineToolResults =
params.inlineToolResultsAllowed && params.verboseLevel === "on" && params.toolMetas.length > 0;
params.inlineToolResultsAllowed &&
params.verboseLevel !== "off" &&
params.toolMetas.length > 0;
if (inlineToolResults) {
for (const { toolName, meta } of params.toolMetas) {
const agg = formatToolAggregate(toolName, meta ? [meta] : []);

View File

@@ -43,6 +43,7 @@ export type EmbeddedRunAttemptParams = {
runId: string;
abortSignal?: AbortSignal;
shouldEmitToolResult?: () => boolean;
shouldEmitToolOutput?: () => boolean;
onPartialReply?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onAssistantMessageStart?: () => void | Promise<void>;
onBlockReply?: (payload: {

View File

@@ -5,12 +5,28 @@ import { normalizeTextForComparison } from "./pi-embedded-helpers.js";
import { isMessagingTool, isMessagingToolSendAction } from "./pi-embedded-messaging.js";
import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js";
import {
extractToolResultText,
extractMessagingToolSend,
isToolResultError,
sanitizeToolResult,
} from "./pi-embedded-subscribe.tools.js";
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
const TOOL_OUTPUT_ALLOWLIST = new Set(["exec", "bash", "process"]);
function extendExecMeta(toolName: string, args: unknown, meta?: string): string | undefined {
const normalized = toolName.trim().toLowerCase();
if (normalized !== "exec" && normalized !== "bash") return meta;
if (!args || typeof args !== "object") return meta;
const record = args as Record<string, unknown>;
const flags: string[] = [];
if (record.pty === true) flags.push("pty");
if (record.elevated === true) flags.push("elevated");
if (flags.length === 0) return meta;
const suffix = flags.join(" · ");
return meta ? `${meta} · ${suffix}` : suffix;
}
export async function handleToolExecutionStart(
ctx: EmbeddedPiSubscribeContext,
evt: AgentEvent & { toolName: string; toolCallId: string; args: unknown },
@@ -36,7 +52,7 @@ export async function handleToolExecutionStart(
}
}
const meta = inferToolMetaFromArgs(toolName, args);
const meta = extendExecMeta(toolName, args, inferToolMetaFromArgs(toolName, args));
ctx.state.toolMetaById.set(toolCallId, meta);
ctx.log.debug(
`embedded run tool start: runId=${ctx.params.runId} tool=${toolName} toolCallId=${toolCallId}`,
@@ -53,8 +69,8 @@ export async function handleToolExecutionStart(
args: args as Record<string, unknown>,
},
});
// Await onAgentEvent to ensure typing indicator starts before tool summaries are emitted.
await ctx.params.onAgentEvent?.({
// Best-effort typing signal; do not block tool summaries on slow emitters.
void ctx.params.onAgentEvent?.({
stream: "tool",
data: { phase: "start", name: toolName, toolCallId },
});
@@ -185,4 +201,15 @@ export function handleToolExecutionEnd(
ctx.log.debug(
`embedded run tool end: runId=${ctx.params.runId} tool=${toolName} toolCallId=${toolCallId}`,
);
if (
ctx.params.onToolResult &&
ctx.shouldEmitToolOutput() &&
TOOL_OUTPUT_ALLOWLIST.has(toolName.trim().toLowerCase())
) {
const outputText = extractToolResultText(sanitizedResult);
if (outputText) {
ctx.emitToolOutput(toolName, meta, outputText);
}
}
}

View File

@@ -32,7 +32,7 @@ export function createEmbeddedPiSessionEventHandler(ctx: EmbeddedPiSubscribeCont
handleMessageEnd(ctx, evt as never);
return;
case "tool_execution_start":
// Async handler - awaits typing indicator before emitting tool summaries.
// Async handler - best-effort typing indicator, avoids blocking tool summaries.
// Catch rejections to avoid unhandled promise rejection crashes.
handleToolExecutionStart(ctx, evt as never).catch((err) => {
ctx.log.debug(`tool_execution_start handler failed: ${String(err)}`);

View File

@@ -56,7 +56,9 @@ export type EmbeddedPiSubscribeContext = {
blockChunker: EmbeddedBlockChunker | null;
shouldEmitToolResult: () => boolean;
shouldEmitToolOutput: () => boolean;
emitToolSummary: (toolName?: string, meta?: string) => void;
emitToolOutput: (toolName?: string, meta?: string, output?: string) => void;
stripBlockTags: (
text: string,
state: { thinking: boolean; final: boolean; inlineCode?: InlineCodeState },

View File

@@ -131,4 +131,52 @@ describe("subscribeEmbeddedPiSession", () => {
expect(payload.text).toContain("snapshot");
expect(payload.text).toContain("https://example.com");
});
it("emits exec output in full verbose mode and includes PTY indicator", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onToolResult = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run-exec-full",
verboseLevel: "full",
onToolResult,
});
handler?.({
type: "tool_execution_start",
toolName: "exec",
toolCallId: "tool-exec-1",
args: { command: "claude", pty: true },
});
await Promise.resolve();
expect(onToolResult).toHaveBeenCalledTimes(1);
const summary = onToolResult.mock.calls[0][0];
expect(summary.text).toContain("exec");
expect(summary.text).toContain("pty");
handler?.({
type: "tool_execution_end",
toolName: "exec",
toolCallId: "tool-exec-1",
isError: false,
result: { content: [{ type: "text", text: "hello\nworld" }] },
});
await Promise.resolve();
expect(onToolResult).toHaveBeenCalledTimes(2);
const output = onToolResult.mock.calls[1][0];
expect(output.text).toContain("hello");
expect(output.text).toContain("```txt");
});
});

View File

@@ -33,6 +33,24 @@ export function sanitizeToolResult(result: unknown): unknown {
return { ...record, content: sanitized };
}
export function extractToolResultText(result: unknown): string | undefined {
if (!result || typeof result !== "object") return undefined;
const record = result as Record<string, unknown>;
const content = Array.isArray(record.content) ? record.content : null;
if (!content) return undefined;
const texts = content
.map((item) => {
if (!item || typeof item !== "object") return undefined;
const entry = item as Record<string, unknown>;
if (entry.type !== "text" || typeof entry.text !== "string") return undefined;
const trimmed = entry.text.trim();
return trimmed ? trimmed : undefined;
})
.filter((value): value is string => Boolean(value));
if (texts.length === 0) return undefined;
return texts.join("\n");
}
export function isToolResultError(result: unknown): boolean {
if (!result || typeof result !== "object") return false;
const record = result as { details?: unknown };

View File

@@ -172,7 +172,16 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
const shouldEmitToolResult = () =>
typeof params.shouldEmitToolResult === "function"
? params.shouldEmitToolResult()
: params.verboseLevel === "on";
: params.verboseLevel === "on" || params.verboseLevel === "full";
const shouldEmitToolOutput = () =>
typeof params.shouldEmitToolOutput === "function"
? params.shouldEmitToolOutput()
: params.verboseLevel === "full";
const formatToolOutputBlock = (text: string) => {
const trimmed = text.trim();
if (!trimmed) return "(no output)";
return `\`\`\`txt\n${trimmed}\n\`\`\``;
};
const emitToolSummary = (toolName?: string, meta?: string) => {
if (!params.onToolResult) return;
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined);
@@ -187,6 +196,21 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
// ignore tool result delivery failures
}
};
const emitToolOutput = (toolName?: string, meta?: string, output?: string) => {
if (!params.onToolResult || !output) return;
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined);
const message = `${agg}\n${formatToolOutputBlock(output)}`;
const { text: cleanedText, mediaUrls } = parseReplyDirectives(message);
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) return;
try {
void params.onToolResult({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
});
} catch {
// ignore tool result delivery failures
}
};
const stripBlockTags = (
text: string,
@@ -363,7 +387,9 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
blockChunking,
blockChunker,
shouldEmitToolResult,
shouldEmitToolOutput,
emitToolSummary,
emitToolOutput,
stripBlockTags,
emitBlockChunk,
flushBlockReplyBuffer,

View File

@@ -1,14 +1,15 @@
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import type { ReasoningLevel } from "../auto-reply/thinking.js";
import type { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js";
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
export type SubscribeEmbeddedPiSessionParams = {
session: AgentSession;
runId: string;
verboseLevel?: "off" | "on";
verboseLevel?: VerboseLevel;
reasoningMode?: ReasoningLevel;
shouldEmitToolResult?: () => boolean;
shouldEmitToolOutput?: () => boolean;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onBlockReply?: (payload: {

View File

@@ -30,6 +30,12 @@
"title": "Exec",
"detailKeys": ["command"]
},
"bash": {
"emoji": "🛠️",
"title": "Exec",
"label": "exec",
"detailKeys": ["command"]
},
"process": {
"emoji": "🧰",
"title": "Process",