diff --git a/src/agents/pi-extensions/context-pruning/pruner.ts b/src/agents/pi-extensions/context-pruning/pruner.ts index c13e5c37a..06451fa95 100644 --- a/src/agents/pi-extensions/context-pruning/pruner.ts +++ b/src/agents/pi-extensions/context-pruning/pruner.ts @@ -5,6 +5,76 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import type { EffectiveContextPruningSettings } from "./settings.js"; import { makeToolPrunablePredicate } from "./tools.js"; +interface ToolCallLike { + id: string; + name?: string; +} + +function extractToolCallsFromAssistant( + msg: Extract, +): ToolCallLike[] { + const content = msg.content; + if (!Array.isArray(content)) return []; + + const toolCalls: ToolCallLike[] = []; + for (const block of content) { + if (!block || typeof block !== "object") continue; + const rec = block as { type?: unknown; id?: unknown; name?: unknown }; + if (typeof rec.id !== "string" || !rec.id) continue; + if (rec.type === "tool_use" || rec.type === "toolCall") { + toolCalls.push({ + id: rec.id, + name: typeof rec.name === "string" ? rec.name : undefined, + }); + } + } + return toolCalls; +} + +function extractToolResultId(msg: Extract): string | null { + const toolCallId = (msg as { toolCallId?: unknown }).toolCallId; + if (typeof toolCallId === "string" && toolCallId) return toolCallId; + const toolUseId = (msg as { toolUseId?: unknown }).toolUseId; + if (typeof toolUseId === "string" && toolUseId) return toolUseId; + return null; +} + +/** + * Validates that a tool_result at the given index has a corresponding tool_use + * in the previous assistant message. This prevents orphaned tool_result blocks + * that would cause API validation errors. + */ +function hasCorrespondingToolUse(messages: AgentMessage[], toolResultIndex: number): boolean { + const toolResult = messages[toolResultIndex]; + if (!toolResult || toolResult.role !== "toolResult") return false; + + const toolResultId = extractToolResultId( + toolResult as Extract, + ); + if (!toolResultId) return false; + + // Scan backwards to find the assistant message with the matching tool_use + for (let j = toolResultIndex - 1; j >= 0; j--) { + const prevMsg = messages[j]; + if (!prevMsg) continue; + + // Stop at the first assistant message (tool_results must follow their assistant message) + if (prevMsg.role === "assistant") { + const toolCalls = extractToolCallsFromAssistant( + prevMsg as Extract, + ); + return toolCalls.some((tc) => tc.id === toolResultId); + } + + // Stop if we hit a user message (shouldn't happen in valid history) + if (prevMsg.role === "user") { + return false; + } + } + + return false; +} + const CHARS_PER_TOKEN_ESTIMATE = 4; // We currently skip pruning tool results that contain images. Still, we count them (approx.) so // we start trimming prunable tool results earlier when image-heavy context is consuming the window. @@ -228,6 +298,12 @@ export function pruneContextMessages(params: { if (hasImageBlocks(msg.content)) { continue; } + // CRITICAL: Validate that this tool_result has a corresponding tool_use + // in the previous assistant message. Pruning orphaned tool_results causes + // API validation errors: "unexpected tool_use_id found in tool_result blocks" + if (!hasCorrespondingToolUse(messages, i)) { + continue; + } prunableToolIndexes.push(i); const updated = softTrimToolResultMessage({ diff --git a/src/telegram/api-logging.ts b/src/telegram/api-logging.ts index 110fd4e34..802bdb708 100644 --- a/src/telegram/api-logging.ts +++ b/src/telegram/api-logging.ts @@ -21,6 +21,35 @@ function resolveTelegramApiLogger(runtime?: RuntimeEnv, logger?: TelegramApiLogg return (message: string) => fallbackLogger.error(message); } +function isNetworkError(err: unknown): boolean { + const errStr = String(err).toLowerCase(); + return ( + errStr.includes("network request") || + errStr.includes("fetch failed") || + errStr.includes("econnrefused") || + errStr.includes("enotfound") || + errStr.includes("etimedout") || + errStr.includes("econnreset") + ); +} + +function buildErrorMessage(operation: string, err: unknown): string { + const errText = formatErrorMessage(err); + let message = `telegram ${operation} failed: ${errText}`; + + // Add helpful hints for network errors + if (isNetworkError(err)) { + message += + "\nTroubleshooting network errors:\n" + + " 1. Check internet connectivity to api.telegram.org\n" + + " 2. If using Node 22-23, try: clawdbot config set channels.telegram.network.autoSelectFamily true\n" + + " 3. Check for proxy/firewall blocking Telegram API\n" + + " 4. Verify no local DNS resolution issues"; + } + + return message; +} + export async function withTelegramApiErrorLogging({ operation, fn, @@ -32,9 +61,9 @@ export async function withTelegramApiErrorLogging({ return await fn(); } catch (err) { if (!shouldLog || shouldLog(err)) { - const errText = formatErrorMessage(err); + const message = buildErrorMessage(operation, err); const log = resolveTelegramApiLogger(runtime, logger); - log(danger(`telegram ${operation} failed: ${errText}`)); + log(danger(message)); } throw err; } diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts index ebed468c9..d47deb925 100644 --- a/src/telegram/fetch.ts +++ b/src/telegram/fetch.ts @@ -7,8 +7,9 @@ import { resolveTelegramAutoSelectFamilyDecision } from "./network-config.js"; let appliedAutoSelectFamily: boolean | null = null; const log = createSubsystemLogger("telegram/network"); -// Node 22 workaround: disable autoSelectFamily to avoid Happy Eyeballs timeouts. +// Node 22-23 workaround: disable autoSelectFamily to avoid Happy Eyeballs timeouts. // See: https://github.com/nodejs/node/issues/54359 +// This issue appears to be resolved in Node 24+. function applyTelegramNetworkWorkarounds(network?: TelegramNetworkConfig): void { const decision = resolveTelegramAutoSelectFamilyDecision({ network }); if (decision.value === null || decision.value === appliedAutoSelectFamily) return; diff --git a/src/telegram/network-config.ts b/src/telegram/network-config.ts index 4e8560105..f2de91a7f 100644 --- a/src/telegram/network-config.ts +++ b/src/telegram/network-config.ts @@ -32,8 +32,13 @@ export function resolveTelegramAutoSelectFamilyDecision(params?: { if (typeof params?.network?.autoSelectFamily === "boolean") { return { value: params.network.autoSelectFamily, source: "config" }; } - if (Number.isFinite(nodeMajor) && nodeMajor >= 22) { - return { value: false, source: "default-node22" }; + // Node 22-23 workaround: disable autoSelectFamily to avoid Happy Eyeballs timeouts. + // See: https://github.com/nodejs/node/issues/54359 + // This issue appears to be resolved in Node 24+, where autoSelectFamily works correctly. + if (Number.isFinite(nodeMajor) && nodeMajor >= 22 && nodeMajor < 24) { + return { value: false, source: "default-node22-23" }; } + // Node 24+: Use Node's default behavior (autoSelectFamily enabled by default) + // Node 25 testing shows autoSelectFamily=true works correctly for Telegram API return { value: null }; }