feat: extend verbose tool feedback
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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] : []);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
"title": "Exec",
|
||||
"detailKeys": ["command"]
|
||||
},
|
||||
"bash": {
|
||||
"emoji": "🛠️",
|
||||
"title": "Exec",
|
||||
"label": "exec",
|
||||
"detailKeys": ["command"]
|
||||
},
|
||||
"process": {
|
||||
"emoji": "🧰",
|
||||
"title": "Process",
|
||||
|
||||
Reference in New Issue
Block a user