Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions .claude/settings.local.json

This file was deleted.

4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ Thumbs.db

# OpenCode
.opencode/
AGENTS.md

# Claude
.claude/

# Tests (local development only)
tests/
Expand Down
20 changes: 6 additions & 14 deletions lib/core/janitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
sendUnifiedNotification,
type NotificationContext
} from "../ui/notification"
import { findCurrentAgent } from "../ui/display-utils"

export interface SessionStats {
totalToolsPruned: number
Expand All @@ -21,7 +22,7 @@ export interface SessionStats {

export interface GCStats {
tokensCollected: number
toolsDeduped: number
toolsGCd: number // Tools garbage collected (deduped outputs + error-pruned inputs)
}

export interface PruningResult {
Expand Down Expand Up @@ -181,7 +182,7 @@ async function runWithStrategies(
totalToolsPruned: currentStats.totalToolsPruned + finalNewlyPrunedIds.length,
totalTokensSaved: currentStats.totalTokensSaved + tokensSaved,
totalGCTokens: currentStats.totalGCTokens + (gcPending?.tokensCollected ?? 0),
totalGCTools: currentStats.totalGCTools + (gcPending?.toolsDeduped ?? 0)
totalGCTools: currentStats.totalGCTools + (gcPending?.toolsGCd ?? 0)
}
state.stats.set(sessionID, sessionStats)

Expand All @@ -205,7 +206,7 @@ async function runWithStrategies(

if (finalNewlyPrunedIds.length === 0) {
if (notificationSent) {
logger.info("janitor", `GC-only notification: ~${formatTokenCount(gcPending?.tokensCollected ?? 0)} tokens from ${gcPending?.toolsDeduped ?? 0} deduped tools`, {
logger.info("janitor", `GC-only notification: ~${formatTokenCount(gcPending?.tokensCollected ?? 0)} tokens from ${gcPending?.toolsGCd ?? 0} GC'd tools`, {
trigger: options.trigger
})
}
Expand All @@ -230,7 +231,7 @@ async function runWithStrategies(
}
if (gcPending) {
logMeta.gcTokens = gcPending.tokensCollected
logMeta.gcTools = gcPending.toolsDeduped
logMeta.gcTools = gcPending.toolsGCd
}

logger.info("janitor", `Pruned ${prunedCount}/${candidateCount} tools, ${keptCount} kept (~${formatTokenCount(tokensSaved)} tokens)`, logMeta)
Expand Down Expand Up @@ -437,16 +438,7 @@ export function parseMessages(
return { toolCallIds, toolOutputs, toolMetadata }
}

function findCurrentAgent(messages: any[]): string | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
const info = msg.info
if (info?.role === 'user') {
return info.agent || 'build'
}
}
return undefined
}


// ============================================================================
// Helpers
Expand Down
56 changes: 56 additions & 0 deletions lib/core/strategies/error-pruning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { extractParameterKey } from "../../ui/display-utils"
import type { PruningStrategy, StrategyResult, ToolMetadata } from "./types"

/**
* Minimum number of recent tool calls to protect from error pruning.
* Tools older than this threshold will have their inputs pruned if they errored.
*/
const MIN_AGE_THRESHOLD = 5

/**
* Error pruning strategy - prunes tool inputs (arguments) for tools that
* resulted in an error, provided they are older than MIN_AGE_THRESHOLD.
*
* This helps clean up failed attempts (like bad edits, file not found, etc.)
* while keeping recent errors visible for the model to learn from.
*/
export const errorPruningStrategy: PruningStrategy = {
name: "error-pruning",

detect(
toolMetadata: Map<string, ToolMetadata>,
unprunedIds: string[],
protectedTools: string[]
): StrategyResult {
const prunedIds: string[] = []
const details = new Map()

// Don't prune the last N tool calls - model may still be iterating
if (unprunedIds.length <= MIN_AGE_THRESHOLD) {
return { prunedIds, details }
}

const pruneableIds = unprunedIds.slice(0, -MIN_AGE_THRESHOLD)
const protectedToolsLower = protectedTools.map(t => t.toLowerCase())

for (const id of pruneableIds) {
const meta = toolMetadata.get(id)
if (!meta) continue

// Skip protected tools
if (protectedToolsLower.includes(meta.tool.toLowerCase())) continue

// Check if this tool errored
if (meta.status === "error") {
prunedIds.push(id)
details.set(id, {
toolName: meta.tool,
parameterKey: extractParameterKey(meta),
reason: `error: ${meta.error || "unknown error"}`
})
}
}

return { prunedIds, details }
}
}
17 changes: 6 additions & 11 deletions lib/core/strategies/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@

import type { PruningStrategy, StrategyResult, ToolMetadata } from "./types"
import { deduplicationStrategy } from "./deduplication"
import { errorPruningStrategy } from "./error-pruning"

export type { PruningStrategy, StrategyResult, ToolMetadata, StrategyDetail } from "./types"

/** All available strategies */
const ALL_STRATEGIES: PruningStrategy[] = [
deduplicationStrategy,
errorPruningStrategy,
// Future strategies will be added here:
// errorPruningStrategy,
// writeReadStrategy,
// partialReadStrategy,
]
Expand All @@ -24,31 +25,25 @@ export interface RunStrategiesResult {
}

/**
* Run all enabled strategies and collect pruned IDs.
* Run all GC strategies and collect pruned IDs.
* All strategies in ALL_STRATEGIES are always enabled (garbage collection).
*
* @param toolMetadata - Map of tool call ID to metadata
* @param unprunedIds - Tool call IDs not yet pruned (chronological order)
* @param protectedTools - Tool names that should never be pruned
* @param enabledStrategies - Strategy names to run (defaults to all)
*/
export function runStrategies(
toolMetadata: Map<string, ToolMetadata>,
unprunedIds: string[],
protectedTools: string[],
enabledStrategies?: string[]
protectedTools: string[]
): RunStrategiesResult {
const byStrategy = new Map<string, StrategyResult>()
const allPrunedIds = new Set<string>()

// Filter to enabled strategies (or all if not specified)
const strategies = enabledStrategies
? ALL_STRATEGIES.filter(s => enabledStrategies.includes(s.name))
: ALL_STRATEGIES

// Track which IDs are still available for each strategy
let remainingIds = unprunedIds

for (const strategy of strategies) {
for (const strategy of ALL_STRATEGIES) {
const result = strategy.detect(toolMetadata, remainingIds, protectedTools)

if (result.prunedIds.length > 0) {
Expand Down
2 changes: 2 additions & 0 deletions lib/core/strategies/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
export interface ToolMetadata {
tool: string
parameters?: any
status?: "pending" | "running" | "completed" | "error"
error?: string
}

export interface StrategyResult {
Expand Down
33 changes: 33 additions & 0 deletions lib/fetch-wrapper/formats/bedrock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,39 @@ export const bedrockFormat: FormatDescriptor = {
return replaced
},

replaceToolInput(data: any[], toolId: string, prunedMessage: string, _state: PluginState): boolean {
const toolIdLower = toolId.toLowerCase()
let replaced = false

for (let i = 0; i < data.length; i++) {
const m = data[i]

// Bedrock format: assistant message with toolUse blocks in content
if (m.role === 'assistant' && Array.isArray(m.content)) {
let messageModified = false
const newContent = m.content.map((block: any) => {
if (block.toolUse && block.toolUse.toolUseId?.toLowerCase() === toolIdLower) {
messageModified = true
return {
...block,
toolUse: {
...block.toolUse,
input: { _pruned: prunedMessage }
}
}
}
return block
})
if (messageModified) {
data[i] = { ...m, content: newContent }
replaced = true
}
}
}

return replaced
},

hasToolOutputs(data: any[]): boolean {
for (const m of data) {
if (m.role === 'user' && Array.isArray(m.content)) {
Expand Down
57 changes: 57 additions & 0 deletions lib/fetch-wrapper/formats/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,63 @@ export const geminiFormat: FormatDescriptor = {
return replaced
},

replaceToolInput(data: any[], toolId: string, prunedMessage: string, state: PluginState): boolean {
let positionMapping: Map<string, string> | undefined
for (const [_sessionId, mapping] of state.googleToolCallMapping) {
if (mapping && mapping.size > 0) {
positionMapping = mapping
break
}
}

if (!positionMapping) {
return false
}

const toolIdLower = toolId.toLowerCase()
const toolPositionCounters = new Map<string, number>()
let replaced = false

for (let i = 0; i < data.length; i++) {
const content = data[i]
if (!Array.isArray(content.parts)) continue

let contentModified = false
const newParts = content.parts.map((part: any) => {
// Gemini format: functionCall blocks in model content
if (part.functionCall) {
const funcName = part.functionCall.name?.toLowerCase()
if (funcName) {
const currentIndex = toolPositionCounters.get(funcName) || 0
toolPositionCounters.set(funcName, currentIndex + 1)

const positionKey = `${funcName}:${currentIndex}`
const mappedToolId = positionMapping!.get(positionKey)

if (mappedToolId?.toLowerCase() === toolIdLower) {
contentModified = true
replaced = true
return {
...part,
functionCall: {
...part.functionCall,
args: { _pruned: prunedMessage }
}
}
}
}
}
return part
})

if (contentModified) {
data[i] = { ...content, parts: newParts }
}
}

return replaced
},

hasToolOutputs(data: any[]): boolean {
return data.some((content: any) =>
Array.isArray(content.parts) &&
Expand Down
49 changes: 49 additions & 0 deletions lib/fetch-wrapper/formats/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,55 @@ export const openaiChatFormat: FormatDescriptor = {
return replaced
},

replaceToolInput(data: any[], toolId: string, prunedMessage: string, _state: PluginState): boolean {
const toolIdLower = toolId.toLowerCase()
let replaced = false

for (let i = 0; i < data.length; i++) {
const m = data[i]

// OpenAI Chat format: assistant message with tool_calls array
if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
let messageModified = false
const newToolCalls = m.tool_calls.map((tc: any) => {
if (tc.id?.toLowerCase() === toolIdLower) {
messageModified = true
return {
...tc,
function: {
...tc.function,
arguments: JSON.stringify({ _pruned: prunedMessage })
}
}
}
return tc
})
if (messageModified) {
data[i] = { ...m, tool_calls: newToolCalls }
replaced = true
}
}

// Anthropic format (via OpenAI Chat): tool_use blocks in assistant content
if (m.role === 'assistant' && Array.isArray(m.content)) {
let messageModified = false
const newContent = m.content.map((part: any) => {
if (part.type === 'tool_use' && part.id?.toLowerCase() === toolIdLower) {
messageModified = true
return { ...part, input: { _pruned: prunedMessage } }
}
return part
})
if (messageModified) {
data[i] = { ...m, content: newContent }
replaced = true
}
}
}

return replaced
},

hasToolOutputs(data: any[]): boolean {
for (const m of data) {
if (m.role === 'tool') return true
Expand Down
16 changes: 16 additions & 0 deletions lib/fetch-wrapper/formats/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ export const openaiResponsesFormat: FormatDescriptor = {
return replaced
},

replaceToolInput(data: any[], toolId: string, prunedMessage: string, _state: PluginState): boolean {
const toolIdLower = toolId.toLowerCase()
let replaced = false

for (let i = 0; i < data.length; i++) {
const item = data[i]
// OpenAI Responses format: function_call items with call_id and arguments
if (item.type === 'function_call' && item.call_id?.toLowerCase() === toolIdLower) {
data[i] = { ...item, arguments: JSON.stringify({ _pruned: prunedMessage }) }
replaced = true
}
}

return replaced
},

hasToolOutputs(data: any[]): boolean {
return data.some((item: any) => item.type === 'function_call_output')
},
Expand Down
Loading