fix: format verbose tool output by channel

This commit is contained in:
Peter Steinberger
2026-01-17 10:17:57 +00:00
parent 4ca38286d8
commit 31e8ecca10
12 changed files with 86 additions and 7 deletions

View File

@@ -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.

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<void>;

View File

@@ -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,

View File

@@ -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");

View File

@@ -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}\``;
}

View File

@@ -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<string>([
"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);
}