diff --git a/docs/thinking.md b/docs/thinking.md index 355a97ab3..c95fb765f 100644 --- a/docs/thinking.md +++ b/docs/thinking.md @@ -32,7 +32,7 @@ read_when: - Levels: `on|full` or `off` (default). - Directive-only message toggles session verbose and replies `Verbose logging enabled.` / `Verbose logging disabled.`; invalid levels return a hint without changing state. - Inline directive affects only that message; session/global defaults apply otherwise. -- When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with `[🛠️ ]` when available (path/command); the tool output itself is not forwarded. +- When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with `[🛠️ ]` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. ## Heartbeats - Heartbeat probe body is `HEARTBEAT`. Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats). diff --git a/src/agents/pi-embedded.ts b/src/agents/pi-embedded.ts index 46a16f935..5e54cdc8e 100644 --- a/src/agents/pi-embedded.ts +++ b/src/agents/pi-embedded.ts @@ -221,6 +221,10 @@ export async function runEmbeddedPiAgent(params: { text?: string; mediaUrls?: string[]; }) => void | Promise; + onToolResult?: (payload: { + text?: string; + mediaUrls?: string[]; + }) => void | Promise; onAgentEvent?: (evt: { stream: string; data: Record; @@ -415,6 +419,24 @@ export async function runEmbeddedPiAgent(params: { isError, }, }); + if (params.verboseLevel === "on" && params.onToolResult) { + const agg = formatToolAggregate( + toolName, + meta ? [meta] : undefined, + ); + const { text: cleanedText, mediaUrls } = + splitMediaFromOutput(agg); + if (cleanedText || (mediaUrls && mediaUrls.length > 0)) { + try { + void params.onToolResult({ + text: cleanedText, + mediaUrls: mediaUrls?.length ? mediaUrls : undefined, + }); + } catch { + // ignore tool result delivery failures + } + } + } } if (evt.type === "message_update") { @@ -566,6 +588,7 @@ export async function runEmbeddedPiAgent(params: { const inlineToolResults = params.verboseLevel === "on" && !params.onPartialReply && + !params.onToolResult && toolMetas.length > 0; if (inlineToolResults) { for (const { toolName, meta } of toolMetas) { diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 92789a9e7..56214c6ab 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -760,6 +760,13 @@ export async function getReplyFromConfig( mediaUrls: payload.mediaUrls, }) : undefined, + onToolResult: opts?.onToolResult + ? (payload) => + opts.onToolResult?.({ + text: payload.text, + mediaUrls: payload.mediaUrls, + }) + : undefined, }); const payloadArray = runResult.payloads ?? []; diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 38916c685..27ddace0b 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -2,6 +2,7 @@ export type GetReplyOptions = { onReplyStart?: () => Promise | void; isHeartbeat?: boolean; onPartialReply?: (payload: ReplyPayload) => Promise | void; + onToolResult?: (payload: ReplyPayload) => Promise | void; }; export type ReplyPayload = { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 4415ea455..1ded0540f 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -887,6 +887,55 @@ export async function monitorWebProvider( } } + const responsePrefix = cfg.inbound?.responsePrefix; + let toolSendChain: Promise = Promise.resolve(); + const sendToolResult = (payload: ReplyPayload) => { + if ( + !payload?.text && + !payload?.mediaUrl && + !(payload?.mediaUrls?.length ?? 0) + ) { + return; + } + const toolPayload: ReplyPayload = { ...payload }; + if ( + responsePrefix && + toolPayload.text && + toolPayload.text.trim() !== HEARTBEAT_TOKEN && + !toolPayload.text.startsWith(responsePrefix) + ) { + toolPayload.text = `${responsePrefix} ${toolPayload.text}`; + } + toolSendChain = toolSendChain + .then(async () => { + await deliverWebReply({ + replyResult: toolPayload, + msg, + maxMediaBytes, + replyLogger, + runtime, + connectionId, + skipLog: true, + }); + if (toolPayload.text) { + recentlySent.add(toolPayload.text); + if (recentlySent.size > MAX_RECENT_MESSAGES) { + const firstKey = recentlySent.values().next().value; + if (firstKey) recentlySent.delete(firstKey); + } + } + }) + .catch((err) => { + console.error( + danger( + `Failed sending web tool update to ${msg.from ?? conversationId}: ${String( + err, + )}`, + ), + ); + }); + }; + const replyResult = await (replyResolver ?? getReplyFromConfig)( { Body: combinedBody, @@ -905,6 +954,7 @@ export async function monitorWebProvider( }, { onReplyStart: msg.sendComposing, + onToolResult: sendToolResult, }, ); @@ -919,8 +969,7 @@ export async function monitorWebProvider( return; } - // Apply response prefix if configured (skip for HEARTBEAT_OK to preserve exact match) - const responsePrefix = cfg.inbound?.responsePrefix; + await toolSendChain; for (const replyPayload of replyList) { if (