From 38b1aee8b24c329efffbbe00310e67d8da137e71 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 4 Dec 2025 22:24:01 -0500 Subject: [PATCH 1/7] refactor: consolidate api-formats into fetch-wrapper - Move ToolTracker to fetch-wrapper/tool-tracker.ts - Move prunable list logic to fetch-wrapper/prunable-list.ts - Move session helpers into handler.ts - Delete lib/api-formats/ directory - Update all imports --- index.ts | 2 +- lib/api-formats/synth-instruction.ts | 184 ------------------ lib/fetch-wrapper/handler.ts | 78 +++++++- lib/fetch-wrapper/index.ts | 2 +- .../prunable-list.ts | 55 ------ lib/fetch-wrapper/tool-tracker.ts | 18 ++ lib/fetch-wrapper/types.ts | 61 +----- lib/hooks.ts | 4 +- lib/pruning-tool.ts | 4 +- 9 files changed, 98 insertions(+), 310 deletions(-) delete mode 100644 lib/api-formats/synth-instruction.ts rename lib/{api-formats => fetch-wrapper}/prunable-list.ts (64%) create mode 100644 lib/fetch-wrapper/tool-tracker.ts diff --git a/index.ts b/index.ts index 03633043..55a39b93 100644 --- a/index.ts +++ b/index.ts @@ -7,7 +7,7 @@ import { createPluginState } from "./lib/state" import { installFetchWrapper } from "./lib/fetch-wrapper" import { createPruningTool } from "./lib/pruning-tool" import { createEventHandler, createChatParamsHandler } from "./lib/hooks" -import { createToolTracker } from "./lib/api-formats/synth-instruction" +import { createToolTracker } from "./lib/fetch-wrapper/tool-tracker" import { loadPrompt } from "./lib/core/prompt" const plugin: Plugin = (async (ctx) => { diff --git a/lib/api-formats/synth-instruction.ts b/lib/api-formats/synth-instruction.ts deleted file mode 100644 index 7301c17c..00000000 --- a/lib/api-formats/synth-instruction.ts +++ /dev/null @@ -1,184 +0,0 @@ -export interface ToolTracker { - seenToolResultIds: Set - toolResultCount: number // Tools since last prune - skipNextIdle: boolean - getToolName?: (callId: string) => string | undefined -} - -export function createToolTracker(): ToolTracker { - return { seenToolResultIds: new Set(), toolResultCount: 0, skipNextIdle: false } -} - -export function resetToolTrackerCount(tracker: ToolTracker): void { - tracker.toolResultCount = 0 -} - -/** - * Track new tool results in OpenAI/Anthropic messages. - * Increments toolResultCount only for tools not already seen and not protected. - * Returns the number of NEW tools found (since last call). - */ -export function trackNewToolResults(messages: any[], tracker: ToolTracker, protectedTools: Set): number { - let newCount = 0 - for (const m of messages) { - if (m.role === 'tool' && m.tool_call_id) { - if (!tracker.seenToolResultIds.has(m.tool_call_id)) { - tracker.seenToolResultIds.add(m.tool_call_id) - const toolName = tracker.getToolName?.(m.tool_call_id) - if (!toolName || !protectedTools.has(toolName)) { - tracker.toolResultCount++ - newCount++ - } - } - } else if (m.role === 'user' && Array.isArray(m.content)) { - for (const part of m.content) { - if (part.type === 'tool_result' && part.tool_use_id) { - if (!tracker.seenToolResultIds.has(part.tool_use_id)) { - tracker.seenToolResultIds.add(part.tool_use_id) - const toolName = tracker.getToolName?.(part.tool_use_id) - if (!toolName || !protectedTools.has(toolName)) { - tracker.toolResultCount++ - newCount++ - } - } - } - } - } - } - return newCount -} - -/** - * Track new tool results in Gemini contents. - * Uses position-based tracking since Gemini doesn't have tool call IDs. - * Returns the number of NEW tools found (since last call). - */ -export function trackNewToolResultsGemini(contents: any[], tracker: ToolTracker, protectedTools: Set): number { - let newCount = 0 - let positionCounter = 0 - for (const content of contents) { - if (!Array.isArray(content.parts)) continue - for (const part of content.parts) { - if (part.functionResponse) { - const positionId = `gemini_pos_${positionCounter}` - positionCounter++ - if (!tracker.seenToolResultIds.has(positionId)) { - tracker.seenToolResultIds.add(positionId) - const toolName = part.functionResponse.name - if (!toolName || !protectedTools.has(toolName)) { - tracker.toolResultCount++ - newCount++ - } - } - } - } - } - return newCount -} - -/** - * Track new tool results in OpenAI Responses API input. - * Returns the number of NEW tools found (since last call). - */ -export function trackNewToolResultsResponses(input: any[], tracker: ToolTracker, protectedTools: Set): number { - let newCount = 0 - for (const item of input) { - if (item.type === 'function_call_output' && item.call_id) { - if (!tracker.seenToolResultIds.has(item.call_id)) { - tracker.seenToolResultIds.add(item.call_id) - const toolName = tracker.getToolName?.(item.call_id) - if (!toolName || !protectedTools.has(toolName)) { - tracker.toolResultCount++ - newCount++ - } - } - } - } - return newCount -} - -function isNudgeMessage(msg: any, nudgeText: string): boolean { - if (typeof msg.content === 'string') { - return msg.content === nudgeText - } - return false -} - -export function injectSynth(messages: any[], instruction: string, nudgeText: string): boolean { - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i] - if (msg.role === 'user') { - // Skip nudge messages - find real user message - if (isNudgeMessage(msg, nudgeText)) continue - - if (typeof msg.content === 'string') { - if (msg.content.includes(instruction)) return false - msg.content = msg.content + '\n\n' + instruction - } else if (Array.isArray(msg.content)) { - const alreadyInjected = msg.content.some( - (part: any) => part?.type === 'text' && typeof part.text === 'string' && part.text.includes(instruction) - ) - if (alreadyInjected) return false - msg.content.push({ type: 'text', text: instruction }) - } - return true - } - } - return false -} - -function isNudgeContentGemini(content: any, nudgeText: string): boolean { - if (Array.isArray(content.parts) && content.parts.length === 1) { - const part = content.parts[0] - return part?.text === nudgeText - } - return false -} - -export function injectSynthGemini(contents: any[], instruction: string, nudgeText: string): boolean { - for (let i = contents.length - 1; i >= 0; i--) { - const content = contents[i] - if (content.role === 'user' && Array.isArray(content.parts)) { - // Skip nudge messages - find real user message - if (isNudgeContentGemini(content, nudgeText)) continue - - const alreadyInjected = content.parts.some( - (part: any) => part?.text && typeof part.text === 'string' && part.text.includes(instruction) - ) - if (alreadyInjected) return false - content.parts.push({ text: instruction }) - return true - } - } - return false -} - -function isNudgeItemResponses(item: any, nudgeText: string): boolean { - if (typeof item.content === 'string') { - return item.content === nudgeText - } - return false -} - -export function injectSynthResponses(input: any[], instruction: string, nudgeText: string): boolean { - for (let i = input.length - 1; i >= 0; i--) { - const item = input[i] - if (item.type === 'message' && item.role === 'user') { - // Skip nudge messages - find real user message - if (isNudgeItemResponses(item, nudgeText)) continue - - if (typeof item.content === 'string') { - if (item.content.includes(instruction)) return false - item.content = item.content + '\n\n' + instruction - } else if (Array.isArray(item.content)) { - const alreadyInjected = item.content.some( - (part: any) => part?.type === 'input_text' && typeof part.text === 'string' && part.text.includes(instruction) - ) - if (alreadyInjected) return false - item.content.push({ type: 'input_text', text: instruction }) - } - return true - } - } - return false -} diff --git a/lib/fetch-wrapper/handler.ts b/lib/fetch-wrapper/handler.ts index 004378dd..5ff8bfb0 100644 --- a/lib/fetch-wrapper/handler.ts +++ b/lib/fetch-wrapper/handler.ts @@ -1,10 +1,74 @@ -import type { FetchHandlerContext, FetchHandlerResult, FormatDescriptor } from "./types" -import { - PRUNED_CONTENT_MESSAGE, - getAllPrunedIds, - fetchSessionMessages -} from "./types" -import { buildPrunableToolsList, buildEndInjection } from "../api-formats/prunable-list" +import type { FetchHandlerContext, FetchHandlerResult, FormatDescriptor, PrunedIdData } from "./types" +import { type PluginState, ensureSessionRestored } from "../state" +import type { Logger } from "../logger" +import { buildPrunableToolsList, buildEndInjection } from "./prunable-list" + +// ============================================================================ +// Constants +// ============================================================================ + +/** The message used to replace pruned tool output content */ +const PRUNED_CONTENT_MESSAGE = '[Output removed to save context - information superseded or no longer needed]' + +// ============================================================================ +// Session Helpers +// ============================================================================ + +/** + * Get the most recent active (non-subagent) session. + */ +function getMostRecentActiveSession(allSessions: any): any | undefined { + const activeSessions = allSessions.data?.filter((s: any) => !s.parentID) || [] + return activeSessions.length > 0 ? activeSessions[0] : undefined +} + +/** + * Fetch session messages for logging purposes. + */ +async function fetchSessionMessages( + client: any, + sessionId: string +): Promise { + try { + const messagesResponse = await client.session.messages({ + path: { id: sessionId }, + query: { limit: 100 } + }) + return Array.isArray(messagesResponse.data) + ? messagesResponse.data + : Array.isArray(messagesResponse) ? messagesResponse : undefined + } catch (e) { + return undefined + } +} + +/** + * Get all pruned IDs from the current session. + */ +async function getAllPrunedIds( + client: any, + state: PluginState, + logger?: Logger +): Promise { + const allSessions = await client.session.list() + const allPrunedIds = new Set() + + const currentSession = getMostRecentActiveSession(allSessions) + if (currentSession) { + await ensureSessionRestored(state, currentSession.id, logger) + const prunedIds = state.prunedIds.get(currentSession.id) ?? [] + prunedIds.forEach((id: string) => allPrunedIds.add(id.toLowerCase())) + + if (logger && prunedIds.length > 0) { + logger.debug("fetch", "Loaded pruned IDs for replacement", { + sessionId: currentSession.id, + prunedCount: prunedIds.length + }) + } + } + + return { allSessions, allPrunedIds } +} /** * Generic format handler that processes any API format using a FormatDescriptor. diff --git a/lib/fetch-wrapper/index.ts b/lib/fetch-wrapper/index.ts index 1c14444b..44837822 100644 --- a/lib/fetch-wrapper/index.ts +++ b/lib/fetch-wrapper/index.ts @@ -1,7 +1,7 @@ import type { PluginState } from "../state" import type { Logger } from "../logger" import type { FetchHandlerContext, SynthPrompts } from "./types" -import type { ToolTracker } from "../api-formats/synth-instruction" +import type { ToolTracker } from "./types" import type { PluginConfig } from "../config" import { openaiChatFormat, openaiResponsesFormat, geminiFormat, bedrockFormat } from "./formats" import { handleFormat } from "./handler" diff --git a/lib/api-formats/prunable-list.ts b/lib/fetch-wrapper/prunable-list.ts similarity index 64% rename from lib/api-formats/prunable-list.ts rename to lib/fetch-wrapper/prunable-list.ts index ee580e49..e5328848 100644 --- a/lib/api-formats/prunable-list.ts +++ b/lib/fetch-wrapper/prunable-list.ts @@ -4,10 +4,6 @@ * Builds and injects a single message at the end of the conversation containing: * - Nudge instruction (when toolResultCount > nudge_freq) * - Prunable tools list - * - * Note: The base synthetic instructions (signal_management, context_window_management, - * context_pruning) are still appended to the last user message separately via - * synth-instruction.ts - that behavior is unchanged. */ import { extractParameterKey } from '../ui/display-utils' @@ -106,54 +102,3 @@ export function buildEndInjection( return parts.join('\n\n') } - -// ============================================================================ -// OpenAI Chat / Anthropic Format -// ============================================================================ - -/** - * Injects the prunable list (and optionally nudge) at the end of OpenAI/Anthropic messages. - * Appends a new user message at the end. - */ -export function injectPrunableList( - messages: any[], - injection: string -): boolean { - if (!injection) return false - messages.push({ role: 'user', content: injection }) - return true -} - -// ============================================================================ -// Google/Gemini Format -// ============================================================================ - -/** - * Injects the prunable list (and optionally nudge) at the end of Gemini contents. - * Appends a new user content at the end. - */ -export function injectPrunableListGemini( - contents: any[], - injection: string -): boolean { - if (!injection) return false - contents.push({ role: 'user', parts: [{ text: injection }] }) - return true -} - -// ============================================================================ -// OpenAI Responses API Format -// ============================================================================ - -/** - * Injects the prunable list (and optionally nudge) at the end of OpenAI Responses API input. - * Appends a new user message at the end. - */ -export function injectPrunableListResponses( - input: any[], - injection: string -): boolean { - if (!injection) return false - input.push({ type: 'message', role: 'user', content: injection }) - return true -} diff --git a/lib/fetch-wrapper/tool-tracker.ts b/lib/fetch-wrapper/tool-tracker.ts new file mode 100644 index 00000000..ce1d5b9f --- /dev/null +++ b/lib/fetch-wrapper/tool-tracker.ts @@ -0,0 +1,18 @@ +/** + * Tool tracker for tracking tool results and managing nudge frequency. + */ + +export interface ToolTracker { + seenToolResultIds: Set + toolResultCount: number // Tools since last prune + skipNextIdle: boolean + getToolName?: (callId: string) => string | undefined +} + +export function createToolTracker(): ToolTracker { + return { seenToolResultIds: new Set(), toolResultCount: 0, skipNextIdle: false } +} + +export function resetToolTrackerCount(tracker: ToolTracker): void { + tracker.toolResultCount = 0 +} diff --git a/lib/fetch-wrapper/types.ts b/lib/fetch-wrapper/types.ts index b88bf826..5e00a794 100644 --- a/lib/fetch-wrapper/types.ts +++ b/lib/fetch-wrapper/types.ts @@ -1,10 +1,8 @@ -import { type PluginState, ensureSessionRestored } from "../state" +import type { PluginState } from "../state" import type { Logger } from "../logger" -import type { ToolTracker } from "../api-formats/synth-instruction" import type { PluginConfig } from "../config" - -/** The message used to replace pruned tool output content */ -export const PRUNED_CONTENT_MESSAGE = '[Output removed to save context - information superseded or no longer needed]' +import type { ToolTracker } from "./tool-tracker" +export type { ToolTracker } from "./tool-tracker" // ============================================================================ // Format Descriptor Interface @@ -86,56 +84,3 @@ export interface PrunedIdData { allSessions: any allPrunedIds: Set } - -export async function getAllPrunedIds( - client: any, - state: PluginState, - logger?: Logger -): Promise { - const allSessions = await client.session.list() - const allPrunedIds = new Set() - - const currentSession = getMostRecentActiveSession(allSessions) - if (currentSession) { - await ensureSessionRestored(state, currentSession.id, logger) - const prunedIds = state.prunedIds.get(currentSession.id) ?? [] - prunedIds.forEach((id: string) => allPrunedIds.add(id.toLowerCase())) - - if (logger && prunedIds.length > 0) { - logger.debug("fetch", "Loaded pruned IDs for replacement", { - sessionId: currentSession.id, - prunedCount: prunedIds.length - }) - } - } - - return { allSessions, allPrunedIds } -} - -/** - * Fetch session messages for logging purposes. - */ -export async function fetchSessionMessages( - client: any, - sessionId: string -): Promise { - try { - const messagesResponse = await client.session.messages({ - path: { id: sessionId }, - query: { limit: 100 } - }) - return Array.isArray(messagesResponse.data) - ? messagesResponse.data - : Array.isArray(messagesResponse) ? messagesResponse : undefined - } catch (e) { - return undefined - } -} - -/** - * Get the most recent active (non-subagent) session. - */ -export function getMostRecentActiveSession(allSessions: any): any | undefined { - const activeSessions = allSessions.data?.filter((s: any) => !s.parentID) || [] - return activeSessions.length > 0 ? activeSessions[0] : undefined -} diff --git a/lib/hooks.ts b/lib/hooks.ts index b2e461e8..08b6d018 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -3,8 +3,8 @@ import type { Logger } from "./logger" import type { JanitorContext } from "./core/janitor" import { runOnIdle } from "./core/janitor" import type { PluginConfig, PruningStrategy } from "./config" -import type { ToolTracker } from "./api-formats/synth-instruction" -import { resetToolTrackerCount } from "./api-formats/synth-instruction" +import type { ToolTracker } from "./fetch-wrapper/tool-tracker" +import { resetToolTrackerCount } from "./fetch-wrapper/tool-tracker" import { clearAllMappings } from "./state/id-mapping" export async function isSubagentSession(client: any, sessionID: string): Promise { diff --git a/lib/pruning-tool.ts b/lib/pruning-tool.ts index 13605e87..89f9a03a 100644 --- a/lib/pruning-tool.ts +++ b/lib/pruning-tool.ts @@ -1,8 +1,8 @@ import { tool } from "@opencode-ai/plugin" import type { PluginState } from "./state" import type { PluginConfig } from "./config" -import type { ToolTracker } from "./api-formats/synth-instruction" -import { resetToolTrackerCount } from "./api-formats/synth-instruction" +import type { ToolTracker } from "./fetch-wrapper/tool-tracker" +import { resetToolTrackerCount } from "./fetch-wrapper/tool-tracker" import { isSubagentSession } from "./hooks" import { getActualId } from "./state/id-mapping" import { formatPruningResultForTool, sendUnifiedNotification, type NotificationContext } from "./ui/notification" From 3bd8caf10278d2872a876259d1e43f16c66a0165 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 4 Dec 2025 22:24:05 -0500 Subject: [PATCH 2/7] refactor: normalize helper function names in format files Remove format-specific suffixes (Gemini, Responses) from private helper functions since they're now scoped to their own files. --- lib/fetch-wrapper/formats/bedrock.ts | 79 +++++++++++++++++- lib/fetch-wrapper/formats/gemini.ts | 75 +++++++++++++++-- lib/fetch-wrapper/formats/openai-chat.ts | 80 +++++++++++++++++-- lib/fetch-wrapper/formats/openai-responses.ts | 73 +++++++++++++++-- 4 files changed, 282 insertions(+), 25 deletions(-) diff --git a/lib/fetch-wrapper/formats/bedrock.ts b/lib/fetch-wrapper/formats/bedrock.ts index 26c1ca57..9d0d88bc 100644 --- a/lib/fetch-wrapper/formats/bedrock.ts +++ b/lib/fetch-wrapper/formats/bedrock.ts @@ -1,10 +1,81 @@ -import type { FormatDescriptor, ToolOutput } from "../types" +import type { FormatDescriptor, ToolOutput, ToolTracker } from "../types" import type { PluginState } from "../../state" import type { Logger } from "../../logger" -import type { ToolTracker } from "../../api-formats/synth-instruction" import { cacheToolParametersFromMessages } from "../../state/tool-cache" -import { injectSynth, trackNewToolResults } from "../../api-formats/synth-instruction" -import { injectPrunableList } from "../../api-formats/prunable-list" + +// ============================================================================ +// Format-specific injection helpers (reuses OpenAI Chat logic) +// ============================================================================ + +function isNudgeMessage(msg: any, nudgeText: string): boolean { + if (typeof msg.content === 'string') { + return msg.content === nudgeText + } + return false +} + +function injectSynth(messages: any[], instruction: string, nudgeText: string): boolean { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.role === 'user') { + // Skip nudge messages - find real user message + if (isNudgeMessage(msg, nudgeText)) continue + + if (typeof msg.content === 'string') { + if (msg.content.includes(instruction)) return false + msg.content = msg.content + '\n\n' + instruction + } else if (Array.isArray(msg.content)) { + const alreadyInjected = msg.content.some( + (part: any) => part?.type === 'text' && typeof part.text === 'string' && part.text.includes(instruction) + ) + if (alreadyInjected) return false + msg.content.push({ type: 'text', text: instruction }) + } + return true + } + } + return false +} + +function trackNewToolResults(messages: any[], tracker: ToolTracker, protectedTools: Set): number { + let newCount = 0 + for (const m of messages) { + if (m.role === 'tool' && m.tool_call_id) { + if (!tracker.seenToolResultIds.has(m.tool_call_id)) { + tracker.seenToolResultIds.add(m.tool_call_id) + const toolName = tracker.getToolName?.(m.tool_call_id) + if (!toolName || !protectedTools.has(toolName)) { + tracker.toolResultCount++ + newCount++ + } + } + } else if (m.role === 'user' && Array.isArray(m.content)) { + for (const part of m.content) { + if (part.type === 'tool_result' && part.tool_use_id) { + if (!tracker.seenToolResultIds.has(part.tool_use_id)) { + tracker.seenToolResultIds.add(part.tool_use_id) + const toolName = tracker.getToolName?.(part.tool_use_id) + if (!toolName || !protectedTools.has(toolName)) { + tracker.toolResultCount++ + newCount++ + } + } + } + } + } + } + return newCount +} + +function injectPrunableList(messages: any[], injection: string): boolean { + if (!injection) return false + messages.push({ role: 'user', content: injection }) + return true +} + +// ============================================================================ +// Format Descriptor +// ============================================================================ /** * Format descriptor for AWS Bedrock Converse API. diff --git a/lib/fetch-wrapper/formats/gemini.ts b/lib/fetch-wrapper/formats/gemini.ts index 0eee5d6c..a2f33e58 100644 --- a/lib/fetch-wrapper/formats/gemini.ts +++ b/lib/fetch-wrapper/formats/gemini.ts @@ -1,10 +1,69 @@ -import type { FormatDescriptor, ToolOutput } from "../types" -import { PRUNED_CONTENT_MESSAGE } from "../types" +import type { FormatDescriptor, ToolOutput, ToolTracker } from "../types" import type { PluginState } from "../../state" import type { Logger } from "../../logger" -import type { ToolTracker } from "../../api-formats/synth-instruction" -import { injectSynthGemini, trackNewToolResultsGemini } from "../../api-formats/synth-instruction" -import { injectPrunableListGemini } from "../../api-formats/prunable-list" + +// ============================================================================ +// Format-specific injection helpers +// ============================================================================ + +function isNudgeContent(content: any, nudgeText: string): boolean { + if (Array.isArray(content.parts) && content.parts.length === 1) { + const part = content.parts[0] + return part?.text === nudgeText + } + return false +} + +function injectSynth(contents: any[], instruction: string, nudgeText: string): boolean { + for (let i = contents.length - 1; i >= 0; i--) { + const content = contents[i] + if (content.role === 'user' && Array.isArray(content.parts)) { + // Skip nudge messages - find real user message + if (isNudgeContent(content, nudgeText)) continue + + const alreadyInjected = content.parts.some( + (part: any) => part?.text && typeof part.text === 'string' && part.text.includes(instruction) + ) + if (alreadyInjected) return false + content.parts.push({ text: instruction }) + return true + } + } + return false +} + +function trackNewToolResults(contents: any[], tracker: ToolTracker, protectedTools: Set): number { + let newCount = 0 + let positionCounter = 0 + for (const content of contents) { + if (!Array.isArray(content.parts)) continue + for (const part of content.parts) { + if (part.functionResponse) { + const positionId = `gemini_pos_${positionCounter}` + positionCounter++ + if (!tracker.seenToolResultIds.has(positionId)) { + tracker.seenToolResultIds.add(positionId) + const toolName = part.functionResponse.name + if (!toolName || !protectedTools.has(toolName)) { + tracker.toolResultCount++ + newCount++ + } + } + } + } + } + return newCount +} + +function injectPrunableList(contents: any[], injection: string): boolean { + if (!injection) return false + contents.push({ role: 'user', parts: [{ text: injection }] }) + return true +} + +// ============================================================================ +// Format Descriptor +// ============================================================================ /** * Format descriptor for Google/Gemini API. @@ -36,15 +95,15 @@ export const geminiFormat: FormatDescriptor = { }, injectSynth(data: any[], instruction: string, nudgeText: string): boolean { - return injectSynthGemini(data, instruction, nudgeText) + return injectSynth(data, instruction, nudgeText) }, trackNewToolResults(data: any[], tracker: ToolTracker, protectedTools: Set): number { - return trackNewToolResultsGemini(data, tracker, protectedTools) + return trackNewToolResults(data, tracker, protectedTools) }, injectPrunableList(data: any[], injection: string): boolean { - return injectPrunableListGemini(data, injection) + return injectPrunableList(data, injection) }, extractToolOutputs(data: any[], state: PluginState): ToolOutput[] { diff --git a/lib/fetch-wrapper/formats/openai-chat.ts b/lib/fetch-wrapper/formats/openai-chat.ts index b4810461..1a1ff9e8 100644 --- a/lib/fetch-wrapper/formats/openai-chat.ts +++ b/lib/fetch-wrapper/formats/openai-chat.ts @@ -1,11 +1,81 @@ -import type { FormatDescriptor, ToolOutput } from "../types" -import { PRUNED_CONTENT_MESSAGE } from "../types" +import type { FormatDescriptor, ToolOutput, ToolTracker } from "../types" import type { PluginState } from "../../state" import type { Logger } from "../../logger" -import type { ToolTracker } from "../../api-formats/synth-instruction" import { cacheToolParametersFromMessages } from "../../state/tool-cache" -import { injectSynth, trackNewToolResults } from "../../api-formats/synth-instruction" -import { injectPrunableList } from "../../api-formats/prunable-list" + +// ============================================================================ +// Format-specific injection helpers +// ============================================================================ + +function isNudgeMessage(msg: any, nudgeText: string): boolean { + if (typeof msg.content === 'string') { + return msg.content === nudgeText + } + return false +} + +function injectSynth(messages: any[], instruction: string, nudgeText: string): boolean { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.role === 'user') { + // Skip nudge messages - find real user message + if (isNudgeMessage(msg, nudgeText)) continue + + if (typeof msg.content === 'string') { + if (msg.content.includes(instruction)) return false + msg.content = msg.content + '\n\n' + instruction + } else if (Array.isArray(msg.content)) { + const alreadyInjected = msg.content.some( + (part: any) => part?.type === 'text' && typeof part.text === 'string' && part.text.includes(instruction) + ) + if (alreadyInjected) return false + msg.content.push({ type: 'text', text: instruction }) + } + return true + } + } + return false +} + +function trackNewToolResults(messages: any[], tracker: ToolTracker, protectedTools: Set): number { + let newCount = 0 + for (const m of messages) { + if (m.role === 'tool' && m.tool_call_id) { + if (!tracker.seenToolResultIds.has(m.tool_call_id)) { + tracker.seenToolResultIds.add(m.tool_call_id) + const toolName = tracker.getToolName?.(m.tool_call_id) + if (!toolName || !protectedTools.has(toolName)) { + tracker.toolResultCount++ + newCount++ + } + } + } else if (m.role === 'user' && Array.isArray(m.content)) { + for (const part of m.content) { + if (part.type === 'tool_result' && part.tool_use_id) { + if (!tracker.seenToolResultIds.has(part.tool_use_id)) { + tracker.seenToolResultIds.add(part.tool_use_id) + const toolName = tracker.getToolName?.(part.tool_use_id) + if (!toolName || !protectedTools.has(toolName)) { + tracker.toolResultCount++ + newCount++ + } + } + } + } + } + } + return newCount +} + +function injectPrunableList(messages: any[], injection: string): boolean { + if (!injection) return false + messages.push({ role: 'user', content: injection }) + return true +} + +// ============================================================================ +// Format Descriptor +// ============================================================================ /** * Format descriptor for OpenAI Chat Completions and Anthropic APIs. diff --git a/lib/fetch-wrapper/formats/openai-responses.ts b/lib/fetch-wrapper/formats/openai-responses.ts index 67ac5b7b..f7f0d469 100644 --- a/lib/fetch-wrapper/formats/openai-responses.ts +++ b/lib/fetch-wrapper/formats/openai-responses.ts @@ -1,11 +1,68 @@ -import type { FormatDescriptor, ToolOutput } from "../types" -import { PRUNED_CONTENT_MESSAGE } from "../types" +import type { FormatDescriptor, ToolOutput, ToolTracker } from "../types" import type { PluginState } from "../../state" import type { Logger } from "../../logger" -import type { ToolTracker } from "../../api-formats/synth-instruction" import { cacheToolParametersFromInput } from "../../state/tool-cache" -import { injectSynthResponses, trackNewToolResultsResponses } from "../../api-formats/synth-instruction" -import { injectPrunableListResponses } from "../../api-formats/prunable-list" + +// ============================================================================ +// Format-specific injection helpers +// ============================================================================ + +function isNudgeItem(item: any, nudgeText: string): boolean { + if (typeof item.content === 'string') { + return item.content === nudgeText + } + return false +} + +function injectSynth(input: any[], instruction: string, nudgeText: string): boolean { + for (let i = input.length - 1; i >= 0; i--) { + const item = input[i] + if (item.type === 'message' && item.role === 'user') { + // Skip nudge messages - find real user message + if (isNudgeItem(item, nudgeText)) continue + + if (typeof item.content === 'string') { + if (item.content.includes(instruction)) return false + item.content = item.content + '\n\n' + instruction + } else if (Array.isArray(item.content)) { + const alreadyInjected = item.content.some( + (part: any) => part?.type === 'input_text' && typeof part.text === 'string' && part.text.includes(instruction) + ) + if (alreadyInjected) return false + item.content.push({ type: 'input_text', text: instruction }) + } + return true + } + } + return false +} + +function trackNewToolResults(input: any[], tracker: ToolTracker, protectedTools: Set): number { + let newCount = 0 + for (const item of input) { + if (item.type === 'function_call_output' && item.call_id) { + if (!tracker.seenToolResultIds.has(item.call_id)) { + tracker.seenToolResultIds.add(item.call_id) + const toolName = tracker.getToolName?.(item.call_id) + if (!toolName || !protectedTools.has(toolName)) { + tracker.toolResultCount++ + newCount++ + } + } + } + } + return newCount +} + +function injectPrunableList(input: any[], injection: string): boolean { + if (!injection) return false + input.push({ type: 'message', role: 'user', content: injection }) + return true +} + +// ============================================================================ +// Format Descriptor +// ============================================================================ /** * Format descriptor for OpenAI Responses API (GPT-5 models via sdk.responses()). @@ -31,15 +88,15 @@ export const openaiResponsesFormat: FormatDescriptor = { }, injectSynth(data: any[], instruction: string, nudgeText: string): boolean { - return injectSynthResponses(data, instruction, nudgeText) + return injectSynth(data, instruction, nudgeText) }, trackNewToolResults(data: any[], tracker: ToolTracker, protectedTools: Set): number { - return trackNewToolResultsResponses(data, tracker, protectedTools) + return trackNewToolResults(data, tracker, protectedTools) }, injectPrunableList(data: any[], injection: string): boolean { - return injectPrunableListResponses(data, injection) + return injectPrunableList(data, injection) }, extractToolOutputs(data: any[], state: PluginState): ToolOutput[] { From 1da911d91b286e732fb4df64fc6d96ffda90d2a7 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 4 Dec 2025 22:39:40 -0500 Subject: [PATCH 3/7] chore: remove verbose comments and section separators --- lib/fetch-wrapper/formats/bedrock.ts | 30 +- lib/fetch-wrapper/formats/gemini.ts | 22 +- lib/fetch-wrapper/formats/openai-chat.ts | 20 -- lib/fetch-wrapper/formats/openai-responses.ts | 17 -- lib/fetch-wrapper/handler.ts | 28 -- lib/fetch-wrapper/prunable-list.ts | 33 --- lib/fetch-wrapper/tool-tracker.ts | 4 - lib/fetch-wrapper/types.ts | 38 --- notes/error-input-pruning-research.md | 261 ++++++++++++++++++ 9 files changed, 267 insertions(+), 186 deletions(-) create mode 100644 notes/error-input-pruning-research.md diff --git a/lib/fetch-wrapper/formats/bedrock.ts b/lib/fetch-wrapper/formats/bedrock.ts index 9d0d88bc..bf55068f 100644 --- a/lib/fetch-wrapper/formats/bedrock.ts +++ b/lib/fetch-wrapper/formats/bedrock.ts @@ -3,10 +3,6 @@ import type { PluginState } from "../../state" import type { Logger } from "../../logger" import { cacheToolParametersFromMessages } from "../../state/tool-cache" -// ============================================================================ -// Format-specific injection helpers (reuses OpenAI Chat logic) -// ============================================================================ - function isNudgeMessage(msg: any, nudgeText: string): boolean { if (typeof msg.content === 'string') { return msg.content === nudgeText @@ -18,7 +14,6 @@ function injectSynth(messages: any[], instruction: string, nudgeText: string): b for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i] if (msg.role === 'user') { - // Skip nudge messages - find real user message if (isNudgeMessage(msg, nudgeText)) continue if (typeof msg.content === 'string') { @@ -73,27 +68,15 @@ function injectPrunableList(messages: any[], injection: string): boolean { return true } -// ============================================================================ -// Format Descriptor -// ============================================================================ - /** - * Format descriptor for AWS Bedrock Converse API. - * - * Bedrock format characteristics: - * - Top-level `system` array for system messages - * - `messages` array with only 'user' and 'assistant' roles - * - `inferenceConfig` for model parameters (maxTokens, temperature, etc.) - * - Tool calls: `toolUse` blocks in assistant content with `toolUseId` - * - Tool results: `toolResult` blocks in user content with `toolUseId` - * - Cache points: `cachePoint` blocks that should be preserved + * Bedrock uses top-level `system` array + `inferenceConfig` (distinguishes from OpenAI/Anthropic). + * Tool calls: `toolUse` blocks in assistant content with `toolUseId` + * Tool results: `toolResult` blocks in user content with `toolUseId` */ export const bedrockFormat: FormatDescriptor = { name: 'bedrock', detect(body: any): boolean { - // Bedrock has a top-level system array AND inferenceConfig (not model params in messages) - // This distinguishes it from OpenAI/Anthropic which put system in messages return ( Array.isArray(body.system) && body.inferenceConfig !== undefined && @@ -106,8 +89,7 @@ export const bedrockFormat: FormatDescriptor = { }, cacheToolParameters(data: any[], state: PluginState, logger?: Logger): void { - // Bedrock stores tool calls in assistant message content as toolUse blocks - // We need to extract toolUseId and tool name for later correlation + // Extract toolUseId and tool name from assistant toolUse blocks for (const m of data) { if (m.role === 'assistant' && Array.isArray(m.content)) { for (const block of m.content) { @@ -125,7 +107,6 @@ export const bedrockFormat: FormatDescriptor = { } } } - // Also use the generic message caching for any compatible structures cacheToolParametersFromMessages(data, state, logger) }, @@ -145,7 +126,6 @@ export const bedrockFormat: FormatDescriptor = { const outputs: ToolOutput[] = [] for (const m of data) { - // Bedrock tool results are in user messages as toolResult blocks if (m.role === 'user' && Array.isArray(m.content)) { for (const block of m.content) { if (block.toolResult && block.toolResult.toolUseId) { @@ -170,13 +150,11 @@ export const bedrockFormat: FormatDescriptor = { for (let i = 0; i < data.length; i++) { const m = data[i] - // Tool results are in user messages as toolResult blocks if (m.role === 'user' && Array.isArray(m.content)) { let messageModified = false const newContent = m.content.map((block: any) => { if (block.toolResult && block.toolResult.toolUseId?.toLowerCase() === toolIdLower) { messageModified = true - // Replace the content array inside toolResult with pruned message return { ...block, toolResult: { diff --git a/lib/fetch-wrapper/formats/gemini.ts b/lib/fetch-wrapper/formats/gemini.ts index a2f33e58..6fd34b0e 100644 --- a/lib/fetch-wrapper/formats/gemini.ts +++ b/lib/fetch-wrapper/formats/gemini.ts @@ -2,10 +2,6 @@ import type { FormatDescriptor, ToolOutput, ToolTracker } from "../types" import type { PluginState } from "../../state" import type { Logger } from "../../logger" -// ============================================================================ -// Format-specific injection helpers -// ============================================================================ - function isNudgeContent(content: any, nudgeText: string): boolean { if (Array.isArray(content.parts) && content.parts.length === 1) { const part = content.parts[0] @@ -18,7 +14,6 @@ function injectSynth(contents: any[], instruction: string, nudgeText: string): b for (let i = contents.length - 1; i >= 0; i--) { const content = contents[i] if (content.role === 'user' && Array.isArray(content.parts)) { - // Skip nudge messages - find real user message if (isNudgeContent(content, nudgeText)) continue const alreadyInjected = content.parts.some( @@ -61,18 +56,8 @@ function injectPrunableList(contents: any[], injection: string): boolean { return true } -// ============================================================================ -// Format Descriptor -// ============================================================================ - /** - * Format descriptor for Google/Gemini API. - * - * Uses body.contents array with: - * - parts[].functionCall for tool invocations - * - parts[].functionResponse for tool results - * - * IMPORTANT: Gemini doesn't include tool call IDs in its native format. + * Gemini doesn't include tool call IDs in its native format. * We use position-based correlation via state.googleToolCallMapping which maps * "toolName:index" -> "toolCallId" (populated by hooks.ts from message events). */ @@ -88,10 +73,7 @@ export const geminiFormat: FormatDescriptor = { }, cacheToolParameters(_data: any[], _state: PluginState, _logger?: Logger): void { - // Gemini format doesn't include tool parameters in the request body. - // Tool parameters are captured via message events in hooks.ts and stored - // in state.googleToolCallMapping for position-based correlation. - // No-op here. + // No-op: Gemini tool parameters are captured via message events in hooks.ts }, injectSynth(data: any[], instruction: string, nudgeText: string): boolean { diff --git a/lib/fetch-wrapper/formats/openai-chat.ts b/lib/fetch-wrapper/formats/openai-chat.ts index 1a1ff9e8..1a0ea3cd 100644 --- a/lib/fetch-wrapper/formats/openai-chat.ts +++ b/lib/fetch-wrapper/formats/openai-chat.ts @@ -3,10 +3,6 @@ import type { PluginState } from "../../state" import type { Logger } from "../../logger" import { cacheToolParametersFromMessages } from "../../state/tool-cache" -// ============================================================================ -// Format-specific injection helpers -// ============================================================================ - function isNudgeMessage(msg: any, nudgeText: string): boolean { if (typeof msg.content === 'string') { return msg.content === nudgeText @@ -18,7 +14,6 @@ function injectSynth(messages: any[], instruction: string, nudgeText: string): b for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i] if (msg.role === 'user') { - // Skip nudge messages - find real user message if (isNudgeMessage(msg, nudgeText)) continue if (typeof msg.content === 'string') { @@ -73,21 +68,6 @@ function injectPrunableList(messages: any[], injection: string): boolean { return true } -// ============================================================================ -// Format Descriptor -// ============================================================================ - -/** - * Format descriptor for OpenAI Chat Completions and Anthropic APIs. - * - * OpenAI Chat format: - * - Messages with role='tool' and tool_call_id - * - Assistant messages with tool_calls[] array - * - * Anthropic format: - * - Messages with role='user' containing content[].type='tool_result' and tool_use_id - * - Assistant messages with content[].type='tool_use' - */ export const openaiChatFormat: FormatDescriptor = { name: 'openai-chat', diff --git a/lib/fetch-wrapper/formats/openai-responses.ts b/lib/fetch-wrapper/formats/openai-responses.ts index f7f0d469..b2233e00 100644 --- a/lib/fetch-wrapper/formats/openai-responses.ts +++ b/lib/fetch-wrapper/formats/openai-responses.ts @@ -3,10 +3,6 @@ import type { PluginState } from "../../state" import type { Logger } from "../../logger" import { cacheToolParametersFromInput } from "../../state/tool-cache" -// ============================================================================ -// Format-specific injection helpers -// ============================================================================ - function isNudgeItem(item: any, nudgeText: string): boolean { if (typeof item.content === 'string') { return item.content === nudgeText @@ -18,7 +14,6 @@ function injectSynth(input: any[], instruction: string, nudgeText: string): bool for (let i = input.length - 1; i >= 0; i--) { const item = input[i] if (item.type === 'message' && item.role === 'user') { - // Skip nudge messages - find real user message if (isNudgeItem(item, nudgeText)) continue if (typeof item.content === 'string') { @@ -60,18 +55,6 @@ function injectPrunableList(input: any[], injection: string): boolean { return true } -// ============================================================================ -// Format Descriptor -// ============================================================================ - -/** - * Format descriptor for OpenAI Responses API (GPT-5 models via sdk.responses()). - * - * Uses body.input array with: - * - type='function_call' items for tool calls - * - type='function_call_output' items for tool results - * - type='message' items for user/assistant messages - */ export const openaiResponsesFormat: FormatDescriptor = { name: 'openai-responses', diff --git a/lib/fetch-wrapper/handler.ts b/lib/fetch-wrapper/handler.ts index 5ff8bfb0..1cb13140 100644 --- a/lib/fetch-wrapper/handler.ts +++ b/lib/fetch-wrapper/handler.ts @@ -3,28 +3,13 @@ import { type PluginState, ensureSessionRestored } from "../state" import type { Logger } from "../logger" import { buildPrunableToolsList, buildEndInjection } from "./prunable-list" -// ============================================================================ -// Constants -// ============================================================================ - -/** The message used to replace pruned tool output content */ const PRUNED_CONTENT_MESSAGE = '[Output removed to save context - information superseded or no longer needed]' -// ============================================================================ -// Session Helpers -// ============================================================================ - -/** - * Get the most recent active (non-subagent) session. - */ function getMostRecentActiveSession(allSessions: any): any | undefined { const activeSessions = allSessions.data?.filter((s: any) => !s.parentID) || [] return activeSessions.length > 0 ? activeSessions[0] : undefined } -/** - * Fetch session messages for logging purposes. - */ async function fetchSessionMessages( client: any, sessionId: string @@ -42,9 +27,6 @@ async function fetchSessionMessages( } } -/** - * Get all pruned IDs from the current session. - */ async function getAllPrunedIds( client: any, state: PluginState, @@ -70,16 +52,6 @@ async function getAllPrunedIds( return { allSessions, allPrunedIds } } -/** - * Generic format handler that processes any API format using a FormatDescriptor. - * - * This consolidates the common logic from all format-specific handlers: - * 1. Cache tool parameters - * 2. Inject synthetic instructions (if strategies enabled) - * 3. Build and inject prunable tools list - * 4. Replace pruned tool outputs - * 5. Log and save context - */ export async function handleFormat( body: any, ctx: FetchHandlerContext, diff --git a/lib/fetch-wrapper/prunable-list.ts b/lib/fetch-wrapper/prunable-list.ts index e5328848..e711f2dd 100644 --- a/lib/fetch-wrapper/prunable-list.ts +++ b/lib/fetch-wrapper/prunable-list.ts @@ -1,11 +1,3 @@ -/** - * Prunable tools list and nudge injection for DCP. - * - * Builds and injects a single message at the end of the conversation containing: - * - Nudge instruction (when toolResultCount > nudge_freq) - * - Prunable tools list - */ - import { extractParameterKey } from '../ui/display-utils' import { getOrCreateNumericId } from '../state/id-mapping' @@ -14,10 +6,6 @@ export interface ToolMetadata { parameters?: any } -// ============================================================================ -// Prompt Content -// ============================================================================ - const SYSTEM_REMINDER = ` These instructions are injected by a plugin and are invisible to the user. Do not acknowledge or reference them in your response - simply follow them silently. ` @@ -26,19 +14,11 @@ const NUDGE_INSTRUCTION = ` You have accumulated several tool outputs. If you have completed a discrete unit of work and distilled relevant understanding in writing for the user to keep, use the prune tool to remove obsolete tool outputs from this conversation and optimize token usage. ` -// ============================================================================ -// List Building -// ============================================================================ - export interface PrunableListResult { list: string numericIds: number[] } -/** - * Builds the prunable tools list section. - * Returns both the formatted list and the numeric IDs for logging. - */ export function buildPrunableToolsList( sessionId: string, unprunedToolCallIds: string[], @@ -50,16 +30,12 @@ export function buildPrunableToolsList( for (const actualId of unprunedToolCallIds) { const metadata = toolMetadata.get(actualId) - - // Skip if no metadata or if tool is protected if (!metadata) continue if (protectedTools.includes(metadata.tool)) continue - // Get or create numeric ID for this tool call const numericId = getOrCreateNumericId(sessionId, actualId) numericIds.push(numericId) - // Format: "1: read, src/components/Button.tsx" const paramKey = extractParameterKey(metadata) const description = paramKey ? `${metadata.tool}, ${paramKey}` : metadata.tool lines.push(`${numericId}: ${description}`) @@ -75,19 +51,10 @@ export function buildPrunableToolsList( } } -/** - * Builds the end-of-conversation injection message. - * Contains the system reminder, nudge (if active), and the prunable tools list. - * - * @param prunableList - The prunable tools list string (or empty string if none) - * @param includeNudge - Whether to include the nudge instruction - * @returns The injection string, or empty string if nothing to inject - */ export function buildEndInjection( prunableList: string, includeNudge: boolean ): string { - // If no prunable tools, don't inject anything if (!prunableList) { return '' } diff --git a/lib/fetch-wrapper/tool-tracker.ts b/lib/fetch-wrapper/tool-tracker.ts index ce1d5b9f..40489253 100644 --- a/lib/fetch-wrapper/tool-tracker.ts +++ b/lib/fetch-wrapper/tool-tracker.ts @@ -1,7 +1,3 @@ -/** - * Tool tracker for tracking tool results and managing nudge frequency. - */ - export interface ToolTracker { seenToolResultIds: Set toolResultCount: number // Tools since last prune diff --git a/lib/fetch-wrapper/types.ts b/lib/fetch-wrapper/types.ts index 5e00a794..bde8f185 100644 --- a/lib/fetch-wrapper/types.ts +++ b/lib/fetch-wrapper/types.ts @@ -4,64 +4,30 @@ import type { PluginConfig } from "../config" import type { ToolTracker } from "./tool-tracker" export type { ToolTracker } from "./tool-tracker" -// ============================================================================ -// Format Descriptor Interface -// ============================================================================ - -/** Represents a tool output that can be pruned */ export interface ToolOutput { - /** The tool call ID (tool_call_id, call_id, tool_use_id, or position key for Gemini) */ id: string - /** The tool name (for protected tool checking) */ toolName?: string } -/** - * Describes how to handle a specific API format (OpenAI Chat, Anthropic, Gemini, etc.) - * Each format implements this interface to provide format-specific logic. - */ export interface FormatDescriptor { - /** Human-readable name for logging */ name: string - - /** Check if this format matches the request body */ detect(body: any): boolean - - /** Get the data array to process (messages, contents, input, etc.) */ getDataArray(body: any): any[] | undefined - - /** Cache tool parameters from the data array */ cacheToolParameters(data: any[], state: PluginState, logger?: Logger): void - - /** Inject synthetic instruction into the last user message */ injectSynth(data: any[], instruction: string, nudgeText: string): boolean - - /** Track new tool results for nudge frequency */ trackNewToolResults(data: any[], tracker: ToolTracker, protectedTools: Set): number - - /** Inject prunable list at end of conversation */ injectPrunableList(data: any[], injection: string): boolean - - /** Extract all tool outputs from the data for pruning */ extractToolOutputs(data: any[], state: PluginState): ToolOutput[] - - /** Replace a pruned tool output with the pruned message. Returns true if replaced. */ replaceToolOutput(data: any[], toolId: string, prunedMessage: string, state: PluginState): boolean - - /** Check if data has any tool outputs worth processing */ hasToolOutputs(data: any[]): boolean - - /** Get metadata for logging after replacements */ getLogMetadata(data: any[], replacedCount: number, inputUrl: string): Record } -/** Prompts used for synthetic instruction injection */ export interface SynthPrompts { synthInstruction: string nudgeInstruction: string } -/** Context passed to each format-specific handler */ export interface FetchHandlerContext { state: PluginState logger: Logger @@ -71,15 +37,11 @@ export interface FetchHandlerContext { prompts: SynthPrompts } -/** Result from a format handler indicating what happened */ export interface FetchHandlerResult { - /** Whether the body was modified and should be re-serialized */ modified: boolean - /** The potentially modified body object */ body: any } -/** Session data returned from getAllPrunedIds */ export interface PrunedIdData { allSessions: any allPrunedIds: Set diff --git a/notes/error-input-pruning-research.md b/notes/error-input-pruning-research.md new file mode 100644 index 00000000..90c8e439 --- /dev/null +++ b/notes/error-input-pruning-research.md @@ -0,0 +1,261 @@ +# Error-Based Tool Input Pruning - Research & Implementation Plan + +## Feature Overview + +Prune **tool inputs** (not outputs) when the tool execution resulted in an error, provided: +1. The tool is older than the last N tools + +--- + +## Research Findings + +### 1. OpenCode Tool Error Formats + +**Error State Structure:** Tool errors are stored with `status: "error"` and an `error` string field in the message schema (`MessageV2.ToolStateError`). + +**Common error message patterns by tool:** + +| Tool | Error Patterns | +|------|----------------| +| **Edit** | `"oldString not found in content"`, `"File ${filePath} not found"`, `"Found multiple matches for oldString..."`, `"oldString and newString must be different"` | +| **Write** | `"File ${filepath} is not in the current working directory"` | +| **Read** | `"File not found: ${filepath}"`, `"Cannot read binary file: ${filepath}"`, `"The user has blocked you from reading..."` | +| **Bash** | `"Command terminated after exceeding timeout"`, `"Command aborted by user"`, `"Tool execution aborted"` | +| **Grep** | `"pattern is required"`, `"ripgrep failed: ${errorOutput}"` | +| **Patch** | `"Failed to parse patch"`, `"Failed to find context..."`, `"No files were modified"` | +| **Permission** | `"The user rejected permission to use this specific tool call..."` | + +**Error Categories:** +1. **Permission Errors** - User rejected permission or access denied +2. **File System Errors** - File not found, binary file, wrong path +3. **Validation Errors** - Invalid arguments, missing required parameters +4. **Network Errors** - Request failed, timeout, too large +5. **Parse Errors** - Failed to parse patch, command, etc. +6. **State Errors** - File modified, doom loop detected + +### 2. DCP Codebase Architecture + +**Fetch wrapper flow:** +1. Intercepts `globalThis.fetch` → detects API format → calls `handleFormat()` +2. `handleFormat()` caches tool parameters, injects synthetic instructions, replaces pruned outputs +3. Runs automatic deduplication strategy via `runStrategies()` + +**Current data structures:** +- `state.toolParameters: Map` - stores tool INPUTS only +- `state.prunedIds: Map` - tracks pruned IDs +- Tool OUTPUTS are extracted via `format.extractToolOutputs()` but not cached + +**Gap identified:** The system caches tool **inputs** but not tool **outputs**. To detect errored tools, we need to cache output/error information. + +**Strategy Interface:** +```typescript +interface PruningStrategy { + name: string + detect( + toolMetadata: Map, + unprunedIds: string[], + protectedTools: string[] + ): StrategyResult +} +``` + +**Deduplication pattern (reference):** +- Creates signatures from tool name + sorted parameters +- Groups duplicate tool calls by signature +- Keeps most recent occurrence, prunes older ones +- Runs automatically without AI analysis + +--- + +## Architecture Changes Required + +### 1. Enhance State Types (`lib/state/index.ts`) + +Add error tracking to `ToolParameterEntry`: + +```typescript +interface ToolParameterEntry { + tool: string + parameters: any + hasError?: boolean // NEW: true if tool output indicates error + errorContent?: string // NEW: optional error message for classification +} +``` + +### 2. Enhance `cacheToolParameters()` to Also Cache Error Status + +The existing `cacheToolParameters()` already iterates through messages. Extend it to also check tool outputs for error status in the same pass: + +```typescript +// In cacheToolParameters() - after caching tool inputs from assistant messages, +// also check tool result messages for errors +for (const m of messages) { + if (m.role === 'tool' && m.tool_call_id) { + const entry = state.toolParameters.get(m.tool_call_id.toLowerCase()) + if (entry && isErrorOutput(m.content)) { + entry.hasError = true + } + } +} +``` + +### 3. Extend FormatDescriptor Interface (`lib/fetch-wrapper/types.ts`) + +Add method to replace tool inputs: + +```typescript +interface FormatDescriptor { + // ... existing methods + + // NEW: Replace tool INPUT (assistant's tool_call) with pruned message + replaceToolInput(data: T, toolCallId: string, replacement: string, state: PluginState): boolean +} +``` + +### 4. Implement `replaceToolInput()` in Format Files + +For each format (`openai-chat.ts`, `openai-responses.ts`, `bedrock.ts`, `gemini.ts`): + +**`replaceToolInput()`** - Replace assistant's tool_call arguments: +```typescript +// Find assistant message with tool_calls containing this ID +// Replace the arguments with a pruned message +for (const m of messages) { + if (m.role === 'assistant' && m.tool_calls) { + for (const tc of m.tool_calls) { + if (tc.id.toLowerCase() === toolCallId.toLowerCase()) { + tc.function.arguments = JSON.stringify({ + _pruned: "Input removed - tool execution failed" + }) + return true + } + } + } +} +``` + +### 5. Create Error Pruning Strategy (`lib/core/strategies/error-pruning.ts`) + +New strategy following the deduplication pattern: + +```typescript +export const errorPruningStrategy: PruningStrategy = { + name: 'error-pruning', + + detect( + toolMetadata: Map, + unprunedIds: string[], + protectedTools: string[], + config: { minAge: number } // e.g., last N=5 tools are protected + ): StrategyResult { + const prunedIds: string[] = [] + const recentN = config.minAge ?? 5 + + // Don't prune the last N tool calls + const pruneableIds = unprunedIds.slice(0, -recentN) + + for (const id of pruneableIds) { + const meta = toolMetadata.get(id) + if (!meta) continue + + // Skip protected tools + if (protectedTools.includes(meta.tool.toLowerCase())) continue + + // Check if this tool errored + if (meta.hasError) { + prunedIds.push(id) + } + } + + return { prunedIds } + } +} +``` + +### 6. Register Strategy (`lib/core/strategies/index.ts`) + +Add to strategy list and runner: + +```typescript +import { errorPruningStrategy } from './error-pruning' + +const ALL_STRATEGIES: PruningStrategy[] = [ + deduplicationStrategy, + errorPruningStrategy, // NEW +] +``` + +### 7. Extend Handler for Input Replacement (`lib/fetch-wrapper/handler.ts`) + +After output replacement loop, add input replacement: + +```typescript +// Replace pruned tool OUTPUTS (existing) +for (const output of toolOutputs) { + if (allPrunedIds.has(output.id)) { + format.replaceToolOutput(data, output.id, PRUNED_CONTENT_MESSAGE, state) + } +} + +// NEW: Replace pruned tool INPUTS (for error-pruned tools) +for (const prunedId of inputPrunedIds) { + format.replaceToolInput(data, prunedId, PRUNED_INPUT_MESSAGE, state) +} +``` + +### 8. Configuration (`lib/config.ts`) + +Add config options: + +```typescript +interface PluginConfig { + // ... existing + errorPruning: { + enabled: boolean + minAge: number // Don't prune last N tools (default: 5) + protectedTools: string[] // Additional tools to never error-prune + } +} +``` + +--- + +## Files to Modify + +| File | Changes | +|------|---------| +| `lib/state/index.ts` | Add `hasError`, `errorContent` to `ToolParameterEntry` | +| `lib/fetch-wrapper/types.ts` | Add `replaceToolInput` to interface | +| `lib/fetch-wrapper/formats/openai-chat.ts` | Enhance `cacheToolParameters()` for error status, implement `replaceToolInput()` | +| `lib/fetch-wrapper/formats/openai-responses.ts` | Enhance `cacheToolParameters()` for error status, implement `replaceToolInput()` | +| `lib/fetch-wrapper/formats/bedrock.ts` | Enhance `cacheToolParameters()` for error status, implement `replaceToolInput()` | +| `lib/fetch-wrapper/formats/gemini.ts` | Enhance `cacheToolParameters()` for error status, implement `replaceToolInput()` | +| `lib/fetch-wrapper/handler.ts` | Add input replacement loop | +| `lib/core/strategies/error-pruning.ts` | **NEW** - Error pruning strategy | +| `lib/core/strategies/index.ts` | Register new strategy | +| `lib/core/strategies/types.ts` | Add `hasError` to `ToolMetadata` | +| `lib/config.ts` | Add error pruning config options | + +--- + +## Key Considerations + +1. **Age threshold:** Don't prune recent errors - the model may still be iterating on them. Only prune errors older than last N (configurable, default 5) tool calls. + +2. **Input vs Output:** This feature prunes the INPUT (arguments sent to the tool), not the output. The output replacement already happens via deduplication. Input pruning removes the potentially large parameters (like `oldString`/`newString` in edit). + +3. **State persistence:** Error status should be persisted so it survives session restores. + +4. **Error detection is simple:** Tools with `status: "error"` are failures. No content parsing needed - the error state is explicit in the tool result schema. + +--- + +## Implementation Order + +1. Start with state types (`hasError` field) +2. Implement OpenAI Chat format methods first (most common) +3. Add strategy and register it +4. Extend handler for input replacement +5. Add configuration options +6. Implement remaining format methods (Bedrock, Gemini, OpenAI Responses) +7. Test with various error scenarios From 158f8cb35dcb22039a8dfdb90b8d5c9107f1bf62 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 4 Dec 2025 23:00:20 -0500 Subject: [PATCH 4/7] formatting --- index.ts | 2 +- lib/fetch-wrapper/formats/bedrock.ts | 2 +- lib/fetch-wrapper/formats/gemini.ts | 2 +- lib/fetch-wrapper/formats/openai-chat.ts | 2 +- lib/fetch-wrapper/formats/openai-responses.ts | 2 +- lib/fetch-wrapper/gc-tracker.ts | 2 +- lib/fetch-wrapper/handler.ts | 2 +- lib/hooks.ts | 4 ++-- lib/state/tool-cache.ts | 16 ++++++++-------- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/index.ts b/index.ts index 55a39b93..a4536c4e 100644 --- a/index.ts +++ b/index.ts @@ -65,7 +65,7 @@ const plugin: Plugin = (async (ctx) => { // Check for updates after a delay setTimeout(() => { - checkForUpdates(ctx.client, logger, config.showUpdateToasts ?? true).catch(() => {}) + checkForUpdates(ctx.client, logger, config.showUpdateToasts ?? true).catch(() => { }) }, 5000) // Show migration toast if there were config migrations diff --git a/lib/fetch-wrapper/formats/bedrock.ts b/lib/fetch-wrapper/formats/bedrock.ts index bf55068f..405e9d74 100644 --- a/lib/fetch-wrapper/formats/bedrock.ts +++ b/lib/fetch-wrapper/formats/bedrock.ts @@ -15,7 +15,7 @@ function injectSynth(messages: any[], instruction: string, nudgeText: string): b const msg = messages[i] if (msg.role === 'user') { if (isNudgeMessage(msg, nudgeText)) continue - + if (typeof msg.content === 'string') { if (msg.content.includes(instruction)) return false msg.content = msg.content + '\n\n' + instruction diff --git a/lib/fetch-wrapper/formats/gemini.ts b/lib/fetch-wrapper/formats/gemini.ts index 6fd34b0e..4c0508b6 100644 --- a/lib/fetch-wrapper/formats/gemini.ts +++ b/lib/fetch-wrapper/formats/gemini.ts @@ -15,7 +15,7 @@ function injectSynth(contents: any[], instruction: string, nudgeText: string): b const content = contents[i] if (content.role === 'user' && Array.isArray(content.parts)) { if (isNudgeContent(content, nudgeText)) continue - + const alreadyInjected = content.parts.some( (part: any) => part?.text && typeof part.text === 'string' && part.text.includes(instruction) ) diff --git a/lib/fetch-wrapper/formats/openai-chat.ts b/lib/fetch-wrapper/formats/openai-chat.ts index 1a0ea3cd..481da4d2 100644 --- a/lib/fetch-wrapper/formats/openai-chat.ts +++ b/lib/fetch-wrapper/formats/openai-chat.ts @@ -15,7 +15,7 @@ function injectSynth(messages: any[], instruction: string, nudgeText: string): b const msg = messages[i] if (msg.role === 'user') { if (isNudgeMessage(msg, nudgeText)) continue - + if (typeof msg.content === 'string') { if (msg.content.includes(instruction)) return false msg.content = msg.content + '\n\n' + instruction diff --git a/lib/fetch-wrapper/formats/openai-responses.ts b/lib/fetch-wrapper/formats/openai-responses.ts index b2233e00..96ee8587 100644 --- a/lib/fetch-wrapper/formats/openai-responses.ts +++ b/lib/fetch-wrapper/formats/openai-responses.ts @@ -15,7 +15,7 @@ function injectSynth(input: any[], instruction: string, nudgeText: string): bool const item = input[i] if (item.type === 'message' && item.role === 'user') { if (isNudgeItem(item, nudgeText)) continue - + if (typeof item.content === 'string') { if (item.content.includes(instruction)) return false item.content = item.content + '\n\n' + instruction diff --git a/lib/fetch-wrapper/gc-tracker.ts b/lib/fetch-wrapper/gc-tracker.ts index 9119d89c..950a21a1 100644 --- a/lib/fetch-wrapper/gc-tracker.ts +++ b/lib/fetch-wrapper/gc-tracker.ts @@ -14,7 +14,7 @@ export function accumulateGCStats( const tokensCollected = estimateTokensFromOutputs(toolOutputs) const existing = state.gcPending.get(sessionId) ?? { tokensCollected: 0, toolsDeduped: 0 } - + state.gcPending.set(sessionId, { tokensCollected: existing.tokensCollected + tokensCollected, toolsDeduped: existing.toolsDeduped + prunedIds.length diff --git a/lib/fetch-wrapper/handler.ts b/lib/fetch-wrapper/handler.ts index 1cb13140..e6e7c005 100644 --- a/lib/fetch-wrapper/handler.ts +++ b/lib/fetch-wrapper/handler.ts @@ -40,7 +40,7 @@ async function getAllPrunedIds( await ensureSessionRestored(state, currentSession.id, logger) const prunedIds = state.prunedIds.get(currentSession.id) ?? [] prunedIds.forEach((id: string) => allPrunedIds.add(id.toLowerCase())) - + if (logger && prunedIds.length > 0) { logger.debug("fetch", "Loaded pruned IDs for replacement", { sessionId: currentSession.id, diff --git a/lib/hooks.ts b/lib/hooks.ts index 08b6d018..1f54f346 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -116,12 +116,12 @@ export function createChatParamsHandler( if (part.type === 'tool' && part.callID && part.tool) { const toolName = part.tool.toLowerCase() const callId = part.callID.toLowerCase() - + if (!toolCallsByName.has(toolName)) { toolCallsByName.set(toolName, []) } toolCallsByName.get(toolName)!.push(callId) - + if (!state.toolParameters.has(callId)) { state.toolParameters.set(callId, { tool: part.tool, diff --git a/lib/state/tool-cache.ts b/lib/state/tool-cache.ts index f0ae3c6e..eadaba32 100644 --- a/lib/state/tool-cache.ts +++ b/lib/state/tool-cache.ts @@ -32,10 +32,10 @@ export function cacheToolParametersFromMessages( const params = typeof toolCall.function.arguments === 'string' ? JSON.parse(toolCall.function.arguments) : toolCall.function.arguments - state.toolParameters.set(toolCall.id.toLowerCase(), { - tool: toolCall.function.name, - parameters: params - }) + state.toolParameters.set(toolCall.id.toLowerCase(), { + tool: toolCall.function.name, + parameters: params + }) openaiCached++ } catch (error) { } @@ -48,10 +48,10 @@ export function cacheToolParametersFromMessages( continue } - state.toolParameters.set(part.id.toLowerCase(), { - tool: part.name, - parameters: part.input ?? {} - }) + state.toolParameters.set(part.id.toLowerCase(), { + tool: part.name, + parameters: part.input ?? {} + }) anthropicCached++ } } From 85467de864e88a4395d11113ebe0fcbd6e28acd4 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 4 Dec 2025 23:27:04 -0500 Subject: [PATCH 5/7] gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e9287ecd..358c6237 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,4 @@ Thumbs.db # Tests (local development only) tests/ - +notes/ From d977255dc6356eee7cc359f948f1e2d8576cd84a Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 4 Dec 2025 23:27:50 -0500 Subject: [PATCH 6/7] chore: untrack notes directory --- notes/error-input-pruning-research.md | 261 -------------------------- 1 file changed, 261 deletions(-) delete mode 100644 notes/error-input-pruning-research.md diff --git a/notes/error-input-pruning-research.md b/notes/error-input-pruning-research.md deleted file mode 100644 index 90c8e439..00000000 --- a/notes/error-input-pruning-research.md +++ /dev/null @@ -1,261 +0,0 @@ -# Error-Based Tool Input Pruning - Research & Implementation Plan - -## Feature Overview - -Prune **tool inputs** (not outputs) when the tool execution resulted in an error, provided: -1. The tool is older than the last N tools - ---- - -## Research Findings - -### 1. OpenCode Tool Error Formats - -**Error State Structure:** Tool errors are stored with `status: "error"` and an `error` string field in the message schema (`MessageV2.ToolStateError`). - -**Common error message patterns by tool:** - -| Tool | Error Patterns | -|------|----------------| -| **Edit** | `"oldString not found in content"`, `"File ${filePath} not found"`, `"Found multiple matches for oldString..."`, `"oldString and newString must be different"` | -| **Write** | `"File ${filepath} is not in the current working directory"` | -| **Read** | `"File not found: ${filepath}"`, `"Cannot read binary file: ${filepath}"`, `"The user has blocked you from reading..."` | -| **Bash** | `"Command terminated after exceeding timeout"`, `"Command aborted by user"`, `"Tool execution aborted"` | -| **Grep** | `"pattern is required"`, `"ripgrep failed: ${errorOutput}"` | -| **Patch** | `"Failed to parse patch"`, `"Failed to find context..."`, `"No files were modified"` | -| **Permission** | `"The user rejected permission to use this specific tool call..."` | - -**Error Categories:** -1. **Permission Errors** - User rejected permission or access denied -2. **File System Errors** - File not found, binary file, wrong path -3. **Validation Errors** - Invalid arguments, missing required parameters -4. **Network Errors** - Request failed, timeout, too large -5. **Parse Errors** - Failed to parse patch, command, etc. -6. **State Errors** - File modified, doom loop detected - -### 2. DCP Codebase Architecture - -**Fetch wrapper flow:** -1. Intercepts `globalThis.fetch` → detects API format → calls `handleFormat()` -2. `handleFormat()` caches tool parameters, injects synthetic instructions, replaces pruned outputs -3. Runs automatic deduplication strategy via `runStrategies()` - -**Current data structures:** -- `state.toolParameters: Map` - stores tool INPUTS only -- `state.prunedIds: Map` - tracks pruned IDs -- Tool OUTPUTS are extracted via `format.extractToolOutputs()` but not cached - -**Gap identified:** The system caches tool **inputs** but not tool **outputs**. To detect errored tools, we need to cache output/error information. - -**Strategy Interface:** -```typescript -interface PruningStrategy { - name: string - detect( - toolMetadata: Map, - unprunedIds: string[], - protectedTools: string[] - ): StrategyResult -} -``` - -**Deduplication pattern (reference):** -- Creates signatures from tool name + sorted parameters -- Groups duplicate tool calls by signature -- Keeps most recent occurrence, prunes older ones -- Runs automatically without AI analysis - ---- - -## Architecture Changes Required - -### 1. Enhance State Types (`lib/state/index.ts`) - -Add error tracking to `ToolParameterEntry`: - -```typescript -interface ToolParameterEntry { - tool: string - parameters: any - hasError?: boolean // NEW: true if tool output indicates error - errorContent?: string // NEW: optional error message for classification -} -``` - -### 2. Enhance `cacheToolParameters()` to Also Cache Error Status - -The existing `cacheToolParameters()` already iterates through messages. Extend it to also check tool outputs for error status in the same pass: - -```typescript -// In cacheToolParameters() - after caching tool inputs from assistant messages, -// also check tool result messages for errors -for (const m of messages) { - if (m.role === 'tool' && m.tool_call_id) { - const entry = state.toolParameters.get(m.tool_call_id.toLowerCase()) - if (entry && isErrorOutput(m.content)) { - entry.hasError = true - } - } -} -``` - -### 3. Extend FormatDescriptor Interface (`lib/fetch-wrapper/types.ts`) - -Add method to replace tool inputs: - -```typescript -interface FormatDescriptor { - // ... existing methods - - // NEW: Replace tool INPUT (assistant's tool_call) with pruned message - replaceToolInput(data: T, toolCallId: string, replacement: string, state: PluginState): boolean -} -``` - -### 4. Implement `replaceToolInput()` in Format Files - -For each format (`openai-chat.ts`, `openai-responses.ts`, `bedrock.ts`, `gemini.ts`): - -**`replaceToolInput()`** - Replace assistant's tool_call arguments: -```typescript -// Find assistant message with tool_calls containing this ID -// Replace the arguments with a pruned message -for (const m of messages) { - if (m.role === 'assistant' && m.tool_calls) { - for (const tc of m.tool_calls) { - if (tc.id.toLowerCase() === toolCallId.toLowerCase()) { - tc.function.arguments = JSON.stringify({ - _pruned: "Input removed - tool execution failed" - }) - return true - } - } - } -} -``` - -### 5. Create Error Pruning Strategy (`lib/core/strategies/error-pruning.ts`) - -New strategy following the deduplication pattern: - -```typescript -export const errorPruningStrategy: PruningStrategy = { - name: 'error-pruning', - - detect( - toolMetadata: Map, - unprunedIds: string[], - protectedTools: string[], - config: { minAge: number } // e.g., last N=5 tools are protected - ): StrategyResult { - const prunedIds: string[] = [] - const recentN = config.minAge ?? 5 - - // Don't prune the last N tool calls - const pruneableIds = unprunedIds.slice(0, -recentN) - - for (const id of pruneableIds) { - const meta = toolMetadata.get(id) - if (!meta) continue - - // Skip protected tools - if (protectedTools.includes(meta.tool.toLowerCase())) continue - - // Check if this tool errored - if (meta.hasError) { - prunedIds.push(id) - } - } - - return { prunedIds } - } -} -``` - -### 6. Register Strategy (`lib/core/strategies/index.ts`) - -Add to strategy list and runner: - -```typescript -import { errorPruningStrategy } from './error-pruning' - -const ALL_STRATEGIES: PruningStrategy[] = [ - deduplicationStrategy, - errorPruningStrategy, // NEW -] -``` - -### 7. Extend Handler for Input Replacement (`lib/fetch-wrapper/handler.ts`) - -After output replacement loop, add input replacement: - -```typescript -// Replace pruned tool OUTPUTS (existing) -for (const output of toolOutputs) { - if (allPrunedIds.has(output.id)) { - format.replaceToolOutput(data, output.id, PRUNED_CONTENT_MESSAGE, state) - } -} - -// NEW: Replace pruned tool INPUTS (for error-pruned tools) -for (const prunedId of inputPrunedIds) { - format.replaceToolInput(data, prunedId, PRUNED_INPUT_MESSAGE, state) -} -``` - -### 8. Configuration (`lib/config.ts`) - -Add config options: - -```typescript -interface PluginConfig { - // ... existing - errorPruning: { - enabled: boolean - minAge: number // Don't prune last N tools (default: 5) - protectedTools: string[] // Additional tools to never error-prune - } -} -``` - ---- - -## Files to Modify - -| File | Changes | -|------|---------| -| `lib/state/index.ts` | Add `hasError`, `errorContent` to `ToolParameterEntry` | -| `lib/fetch-wrapper/types.ts` | Add `replaceToolInput` to interface | -| `lib/fetch-wrapper/formats/openai-chat.ts` | Enhance `cacheToolParameters()` for error status, implement `replaceToolInput()` | -| `lib/fetch-wrapper/formats/openai-responses.ts` | Enhance `cacheToolParameters()` for error status, implement `replaceToolInput()` | -| `lib/fetch-wrapper/formats/bedrock.ts` | Enhance `cacheToolParameters()` for error status, implement `replaceToolInput()` | -| `lib/fetch-wrapper/formats/gemini.ts` | Enhance `cacheToolParameters()` for error status, implement `replaceToolInput()` | -| `lib/fetch-wrapper/handler.ts` | Add input replacement loop | -| `lib/core/strategies/error-pruning.ts` | **NEW** - Error pruning strategy | -| `lib/core/strategies/index.ts` | Register new strategy | -| `lib/core/strategies/types.ts` | Add `hasError` to `ToolMetadata` | -| `lib/config.ts` | Add error pruning config options | - ---- - -## Key Considerations - -1. **Age threshold:** Don't prune recent errors - the model may still be iterating on them. Only prune errors older than last N (configurable, default 5) tool calls. - -2. **Input vs Output:** This feature prunes the INPUT (arguments sent to the tool), not the output. The output replacement already happens via deduplication. Input pruning removes the potentially large parameters (like `oldString`/`newString` in edit). - -3. **State persistence:** Error status should be persisted so it survives session restores. - -4. **Error detection is simple:** Tools with `status: "error"` are failures. No content parsing needed - the error state is explicit in the tool result schema. - ---- - -## Implementation Order - -1. Start with state types (`hasError` field) -2. Implement OpenAI Chat format methods first (most common) -3. Add strategy and register it -4. Extend handler for input replacement -5. Add configuration options -6. Implement remaining format methods (Bedrock, Gemini, OpenAI Responses) -7. Test with various error scenarios From 8fdc4d3fd8d453819020f8a4ee8b17a9bb4808f3 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 4 Dec 2025 23:35:25 -0500 Subject: [PATCH 7/7] fix: exclude protected tools from total count in log output --- lib/fetch-wrapper/handler.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/fetch-wrapper/handler.ts b/lib/fetch-wrapper/handler.ts index e6e7c005..1231608d 100644 --- a/lib/fetch-wrapper/handler.ts +++ b/lib/fetch-wrapper/handler.ts @@ -117,11 +117,13 @@ export async function handleFormat( const toolOutputs = format.extractToolOutputs(data, ctx.state) const protectedToolsLower = new Set(ctx.config.protectedTools.map(t => t.toLowerCase())) let replacedCount = 0 + let prunableCount = 0 for (const output of toolOutputs) { if (output.toolName && protectedToolsLower.has(output.toolName.toLowerCase())) { continue } + prunableCount++ if (allPrunedIds.has(output.id)) { if (format.replaceToolOutput(data, output.id, PRUNED_CONTENT_MESSAGE, ctx.state)) { @@ -133,7 +135,7 @@ export async function handleFormat( if (replacedCount > 0) { ctx.logger.info("fetch", `Replaced pruned tool outputs (${format.name})`, { replaced: replacedCount, - total: toolOutputs.length + total: prunableCount }) if (ctx.logger.enabled) {