From 31e8ecca10d5e35a05fa0d8720b0a11c6cdf2311 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 10:17:57 +0000 Subject: [PATCH] fix: format verbose tool output by channel --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner/run.ts | 11 +++++++++++ src/agents/pi-embedded-runner/run/attempt.ts | 1 + src/agents/pi-embedded-runner/run/params.ts | 3 ++- src/agents/pi-embedded-runner/run/payloads.ts | 7 ++++++- src/agents/pi-embedded-runner/run/types.ts | 3 ++- src/agents/pi-embedded-subscribe.ts | 12 ++++++++++-- src/agents/pi-embedded-subscribe.types.ts | 3 +++ .../reply/agent-runner-execution.ts | 12 ++++++++++++ src/auto-reply/tool-meta.test.ts | 6 ++++++ src/auto-reply/tool-meta.ts | 19 +++++++++++++++++-- src/utils/message-channel.ts | 15 +++++++++++++++ 12 files changed, 86 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e92a9d6f..23ddbc1d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Docs: https://docs.clawd.bot ### Fixes - macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash. +- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels. - Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z. - Telegram: split long captions into follow-up messages. - Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt. diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 8f3001e23..0e0946428 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import { enqueueCommandInLane } from "../../process/command-queue.js"; import { resolveUserPath } from "../../utils.js"; +import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; import { resolveClawdbotAgentDir } from "../agent-paths.js"; import { markAuthProfileFailure, @@ -58,6 +59,14 @@ export async function runEmbeddedPiAgent( const globalLane = resolveGlobalLane(params.lane); const enqueueGlobal = params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts)); + const channelHint = params.messageChannel ?? params.messageProvider; + const resolvedToolResultFormat = + params.toolResultFormat ?? + (channelHint + ? isMarkdownCapableMessageChannel(channelHint) + ? "markdown" + : "plain" + : "markdown"); return enqueueCommandInLane(sessionLane, () => enqueueGlobal(async () => { @@ -208,6 +217,7 @@ export async function runEmbeddedPiAgent( thinkLevel, verboseLevel: params.verboseLevel, reasoningLevel: params.reasoningLevel, + toolResultFormat: resolvedToolResultFormat, bashElevated: params.bashElevated, timeoutMs: params.timeoutMs, runId: params.runId, @@ -408,6 +418,7 @@ export async function runEmbeddedPiAgent( sessionKey: params.sessionKey ?? params.sessionId, verboseLevel: params.verboseLevel, reasoningLevel: params.reasoningLevel, + toolResultFormat: resolvedToolResultFormat, inlineToolResultsAllowed: !params.onPartialReply && !params.onToolResult, }); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 967a7f5f9..af8cc6b09 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -365,6 +365,7 @@ export async function runEmbeddedAttempt( runId: params.runId, verboseLevel: params.verboseLevel, reasoningMode: params.reasoningLevel ?? "off", + toolResultFormat: params.toolResultFormat, shouldEmitToolResult: params.shouldEmitToolResult, shouldEmitToolOutput: params.shouldEmitToolOutput, onToolResult: params.onToolResult, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 50504c95d..3d4623e66 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -3,7 +3,7 @@ import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-rep import type { ClawdbotConfig } from "../../../config/config.js"; import type { enqueueCommand } from "../../../process/command-queue.js"; import type { ExecElevatedDefaults } from "../../bash-tools.js"; -import type { BlockReplyChunking } from "../../pi-embedded-subscribe.js"; +import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js"; import type { SkillSnapshot } from "../../skills.js"; export type RunEmbeddedPiAgentParams = { @@ -33,6 +33,7 @@ export type RunEmbeddedPiAgentParams = { thinkLevel?: ThinkLevel; verboseLevel?: VerboseLevel; reasoningLevel?: ReasoningLevel; + toolResultFormat?: ToolResultFormat; bashElevated?: ExecElevatedDefaults; timeoutMs: number; runId: string; diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index 09a7e20d3..d9b693098 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -15,6 +15,7 @@ import { extractAssistantThinking, formatReasoningMessage, } from "../../pi-embedded-utils.js"; +import type { ToolResultFormat } from "../../pi-embedded-subscribe.js"; type ToolMetaEntry = { toolName: string; meta?: string }; @@ -26,6 +27,7 @@ export function buildEmbeddedRunPayloads(params: { sessionKey: string; verboseLevel?: VerboseLevel; reasoningLevel?: ReasoningLevel; + toolResultFormat?: ToolResultFormat; inlineToolResultsAllowed: boolean; }): Array<{ text?: string; @@ -47,6 +49,7 @@ export function buildEmbeddedRunPayloads(params: { replyToCurrent?: boolean; }> = []; + const useMarkdown = params.toolResultFormat === "markdown"; const lastAssistantErrored = params.lastAssistant?.stopReason === "error"; const errorText = params.lastAssistant ? formatAssistantErrorText(params.lastAssistant, { @@ -71,7 +74,9 @@ export function buildEmbeddedRunPayloads(params: { params.inlineToolResultsAllowed && params.verboseLevel !== "off" && params.toolMetas.length > 0; if (inlineToolResults) { for (const { toolName, meta } of params.toolMetas) { - const agg = formatToolAggregate(toolName, meta ? [meta] : []); + const agg = formatToolAggregate(toolName, meta ? [meta] : [], { + markdown: useMarkdown, + }); const { text: cleanedText, mediaUrls, diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index 190908e77..1609a4d87 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -6,7 +6,7 @@ import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-rep import type { ClawdbotConfig } from "../../../config/config.js"; import type { ExecElevatedDefaults } from "../../bash-tools.js"; import type { MessagingToolSend } from "../../pi-embedded-messaging.js"; -import type { BlockReplyChunking } from "../../pi-embedded-subscribe.js"; +import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js"; import type { SkillSnapshot } from "../../skills.js"; import type { SessionSystemPromptReport } from "../../../config/sessions/types.js"; @@ -38,6 +38,7 @@ export type EmbeddedRunAttemptParams = { thinkLevel: ThinkLevel; verboseLevel?: VerboseLevel; reasoningLevel?: ReasoningLevel; + toolResultFormat?: ToolResultFormat; bashElevated?: ExecElevatedDefaults; timeoutMs: number; runId: string; diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index aefd84f35..a48fff665 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -23,10 +23,13 @@ const log = createSubsystemLogger("agent/embedded"); export type { BlockReplyChunking, SubscribeEmbeddedPiSessionParams, + ToolResultFormat, } from "./pi-embedded-subscribe.types.js"; export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionParams) { const reasoningMode = params.reasoningMode ?? "off"; + const toolResultFormat = params.toolResultFormat ?? "markdown"; + const useMarkdown = toolResultFormat === "markdown"; const state: EmbeddedPiSubscribeState = { assistantTexts: [], toolMetas: [], @@ -180,11 +183,14 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar const formatToolOutputBlock = (text: string) => { const trimmed = text.trim(); if (!trimmed) return "(no output)"; + if (!useMarkdown) return trimmed; return `\`\`\`txt\n${trimmed}\n\`\`\``; }; const emitToolSummary = (toolName?: string, meta?: string) => { if (!params.onToolResult) return; - const agg = formatToolAggregate(toolName, meta ? [meta] : undefined); + const agg = formatToolAggregate(toolName, meta ? [meta] : undefined, { + markdown: useMarkdown, + }); const { text: cleanedText, mediaUrls } = parseReplyDirectives(agg); if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) return; try { @@ -198,7 +204,9 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar }; const emitToolOutput = (toolName?: string, meta?: string, output?: string) => { if (!params.onToolResult || !output) return; - const agg = formatToolAggregate(toolName, meta ? [meta] : undefined); + const agg = formatToolAggregate(toolName, meta ? [meta] : undefined, { + markdown: useMarkdown, + }); const message = `${agg}\n${formatToolOutputBlock(output)}`; const { text: cleanedText, mediaUrls } = parseReplyDirectives(message); if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) return; diff --git a/src/agents/pi-embedded-subscribe.types.ts b/src/agents/pi-embedded-subscribe.types.ts index dec837863..07953cdd8 100644 --- a/src/agents/pi-embedded-subscribe.types.ts +++ b/src/agents/pi-embedded-subscribe.types.ts @@ -3,11 +3,14 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent"; import type { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js"; import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js"; +export type ToolResultFormat = "markdown" | "plain"; + export type SubscribeEmbeddedPiSessionParams = { session: AgentSession; runId: string; verboseLevel?: VerboseLevel; reasoningMode?: ReasoningLevel; + toolResultFormat?: ToolResultFormat; shouldEmitToolResult?: () => boolean; shouldEmitToolOutput?: () => boolean; onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise; diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 7a528faaa..e756aac9a 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -20,6 +20,10 @@ import { import { logVerbose } from "../../globals.js"; import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; +import { + isMarkdownCapableMessageChannel, + resolveMessageChannel, +} from "../../utils/message-channel.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import type { TemplateContext } from "../templating.js"; import type { VerboseLevel } from "../thinking.js"; @@ -222,6 +226,14 @@ export async function runAgentTurnWithFallback(params: { thinkLevel: params.followupRun.run.thinkLevel, verboseLevel: params.followupRun.run.verboseLevel, reasoningLevel: params.followupRun.run.reasoningLevel, + toolResultFormat: (() => { + const channel = resolveMessageChannel( + params.sessionCtx.Surface, + params.sessionCtx.Provider, + ); + if (!channel) return "markdown"; + return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain"; + })(), bashElevated: params.followupRun.run.bashElevated, timeoutMs: params.followupRun.run.timeoutMs, runId, diff --git a/src/auto-reply/tool-meta.test.ts b/src/auto-reply/tool-meta.test.ts index 3961e9073..1006416fd 100644 --- a/src/auto-reply/tool-meta.test.ts +++ b/src/auto-reply/tool-meta.test.ts @@ -36,6 +36,12 @@ describe("tool meta formatting", () => { expect(out).toContain("a→b"); }); + it("wraps aggregate meta in backticks when markdown is enabled", () => { + vi.stubEnv("HOME", "/Users/test"); + const out = formatToolAggregate("fs", ["/Users/test/dir/a.txt"], { markdown: true }); + expect(out).toContain("`~/dir/a.txt`"); + }); + it("formats prefixes with default labels", () => { vi.stubEnv("HOME", "/Users/test"); expect(formatToolPrefix(undefined, undefined)).toBe("🧩 tool"); diff --git a/src/auto-reply/tool-meta.ts b/src/auto-reply/tool-meta.ts index 9e67c444d..de4deae61 100644 --- a/src/auto-reply/tool-meta.ts +++ b/src/auto-reply/tool-meta.ts @@ -1,6 +1,10 @@ import { formatToolSummary, resolveToolDisplay } from "../agents/tool-display.js"; import { shortenHomeInString, shortenHomePath } from "../utils.js"; +type ToolAggregateOptions = { + markdown?: boolean; +}; + export function shortenPath(p: string): string { return shortenHomePath(p); } @@ -14,7 +18,11 @@ export function shortenMeta(meta: string): string { return `${shortenHomeInString(base)}${rest}`; } -export function formatToolAggregate(toolName?: string, metas?: string[]): string { +export function formatToolAggregate( + toolName?: string, + metas?: string[], + options?: ToolAggregateOptions, +): string { const filtered = (metas ?? []).filter(Boolean).map(shortenMeta); const display = resolveToolDisplay({ name: toolName }); const prefix = `${display.emoji} ${display.label}`; @@ -51,7 +59,8 @@ export function formatToolAggregate(toolName?: string, metas?: string[]): string }); const allSegments = [...rawSegments, ...segments]; - return `${prefix}: ${allSegments.join("; ")}`; + const meta = allSegments.join("; "); + return `${prefix}: ${maybeWrapMarkdown(meta, options?.markdown)}`; } export function formatToolPrefix(toolName?: string, meta?: string) { @@ -68,3 +77,9 @@ function isPathLike(value: string): boolean { if (value.includes("&&") || value.includes("||")) return false; return /^~?(\/[^\s]+)+$/.test(value); } + +function maybeWrapMarkdown(value: string, markdown?: boolean): string { + if (!markdown) return value; + if (value.includes("`")) return value; + return `\`${value}\``; +} diff --git a/src/utils/message-channel.ts b/src/utils/message-channel.ts index 704ea0fd1..ecd1f713b 100644 --- a/src/utils/message-channel.ts +++ b/src/utils/message-channel.ts @@ -17,6 +17,15 @@ import { getActivePluginRegistry } from "../plugins/runtime.js"; export const INTERNAL_MESSAGE_CHANNEL = "webchat" as const; export type InternalMessageChannel = typeof INTERNAL_MESSAGE_CHANNEL; +const MARKDOWN_CAPABLE_CHANNELS = new Set([ + "slack", + "telegram", + "signal", + "discord", + "tui", + INTERNAL_MESSAGE_CHANNEL, +]); + export { GATEWAY_CLIENT_NAMES, GATEWAY_CLIENT_MODES }; export type { GatewayClientName, GatewayClientMode }; export { normalizeGatewayClientName, normalizeGatewayClientMode }; @@ -112,3 +121,9 @@ export function resolveMessageChannel( ): string | undefined { return normalizeMessageChannel(primary) ?? normalizeMessageChannel(fallback); } + +export function isMarkdownCapableMessageChannel(raw?: string | null): boolean { + const channel = normalizeMessageChannel(raw); + if (!channel) return false; + return MARKDOWN_CAPABLE_CHANNELS.has(channel); +}