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
9 changes: 8 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,14 @@ const plugin: Plugin = (async (ctx) => {
event: createEventHandler(ctx.client, janitorCtx, logger, config, toolTracker),
"chat.params": createChatParamsHandler(ctx.client, state, logger),
tool: config.strategies.onTool.length > 0 ? {
prune: createPruningTool(ctx.client, janitorCtx, config, toolTracker),
prune: createPruningTool({
client: ctx.client,
state,
logger,
config,
notificationCtx: janitorCtx.notificationCtx,
workingDirectory: ctx.directory
}, toolTracker),
} : undefined,
}
}) satisfies Plugin
Expand Down
176 changes: 176 additions & 0 deletions lib/api-formats/prunable-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* 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
*
* 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'
import { getOrCreateNumericId, getNumericId } from '../state/id-mapping'

export interface ToolMetadata {
tool: string
parameters?: any
}

// ============================================================================
// Prompt Content
// ============================================================================

const SYSTEM_REMINDER = `<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.
</system-reminder>`

const NUDGE_INSTRUCTION = `<instruction name=agent_nudge>
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.
</instruction>`

// ============================================================================
// 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[],
toolMetadata: Map<string, ToolMetadata>,
protectedTools: string[]
): PrunableListResult {
const lines: string[] = []
const numericIds: number[] = []

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}`)
}

if (lines.length === 0) {
return { list: '', numericIds: [] }
}

return {
list: `<prunable-tools>\n${lines.join('\n')}\n</prunable-tools>`,
numericIds
}
}

/**
* 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 ''
}

const parts = [SYSTEM_REMINDER]

if (includeNudge) {
parts.push(NUDGE_INSTRUCTION)
}

parts.push(prunableList)

return parts.join('\n\n')
}

/**
* Gets the numeric IDs for a list of actual tool call IDs.
* Used when the prune tool needs to show what was pruned.
*/
export function getNumericIdsForActual(
sessionId: string,
actualIds: string[]
): number[] {
return actualIds
.map(id => getNumericId(sessionId, id))
.filter((id): id is number => id !== undefined)
}

// ============================================================================
// Injection Functions
// ============================================================================

// ============================================================================
// 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
}
165 changes: 44 additions & 121 deletions lib/api-formats/synth-instruction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,77 +14,59 @@ export function resetToolTrackerCount(tracker: ToolTracker): void {
tracker.toolResultCount = 0
}

/** Adapter interface for format-specific message operations */
interface MessageFormatAdapter {
countToolResults(messages: any[], tracker: ToolTracker): number
appendNudge(messages: any[], nudgeText: string): void
/**
* Counts total tool results in OpenAI/Anthropic messages (without tracker).
* Used for determining if nudge threshold is met.
*/
export function countToolResults(messages: any[]): number {
let count = 0
for (const m of messages) {
if (m.role === 'tool') {
count++
} else if (m.role === 'user' && Array.isArray(m.content)) {
for (const part of m.content) {
if (part.type === 'tool_result') {
count++
}
}
}
}
return count
}

/**
* Counts total tool results in Gemini contents (without tracker).
*/
export function countToolResultsGemini(contents: any[]): number {
let count = 0
for (const content of contents) {
if (!Array.isArray(content.parts)) continue
for (const part of content.parts) {
if (part.functionResponse) {
count++
}
}
}
return count
}

/** Generic nudge injection - nudges every fetch once tools since last prune exceeds freq */
function injectNudgeCore(
messages: any[],
tracker: ToolTracker,
nudgeText: string,
freq: number,
adapter: MessageFormatAdapter
): boolean {
// Count any new tool results
adapter.countToolResults(messages, tracker)

// Once we've exceeded the threshold, nudge on every fetch
if (tracker.toolResultCount > freq) {
adapter.appendNudge(messages, nudgeText)
return true
/**
* Counts total tool results in OpenAI Responses API input (without tracker).
*/
export function countToolResultsResponses(input: any[]): number {
let count = 0
for (const item of input) {
if (item.type === 'function_call_output') {
count++
}
}
return false
return count
}

// ============================================================================
// OpenAI Chat / Anthropic Format
// ============================================================================

const openaiAdapter: MessageFormatAdapter = {
countToolResults(messages, tracker) {
let newCount = 0
for (const m of messages) {
if (m.role === 'tool' && m.tool_call_id) {
const id = String(m.tool_call_id).toLowerCase()
if (!tracker.seenToolResultIds.has(id)) {
tracker.seenToolResultIds.add(id)
newCount++
const toolName = m.name || tracker.getToolName?.(m.tool_call_id)
if (toolName !== 'prune') {
tracker.skipNextIdle = false
}
}
} else if (m.role === 'user' && Array.isArray(m.content)) {
for (const part of m.content) {
if (part.type === 'tool_result' && part.tool_use_id) {
const id = String(part.tool_use_id).toLowerCase()
if (!tracker.seenToolResultIds.has(id)) {
tracker.seenToolResultIds.add(id)
newCount++
const toolName = tracker.getToolName?.(part.tool_use_id)
if (toolName !== 'prune') {
tracker.skipNextIdle = false
}
}
}
}
}
}
tracker.toolResultCount += newCount
return newCount
},
appendNudge(messages, nudgeText) {
messages.push({ role: 'user', content: nudgeText })
}
}

export function injectNudge(messages: any[], tracker: ToolTracker, nudgeText: string, freq: number): boolean {
return injectNudgeCore(messages, tracker, nudgeText, freq, openaiAdapter)
}

/** Check if a message content matches nudge text (OpenAI/Anthropic format) */
function isNudgeMessage(msg: any, nudgeText: string): boolean {
if (typeof msg.content === 'string') {
Expand Down Expand Up @@ -120,37 +102,6 @@ export function injectSynth(messages: any[], instruction: string, nudgeText: str
// Google/Gemini Format (body.contents with parts)
// ============================================================================

const geminiAdapter: MessageFormatAdapter = {
countToolResults(contents, tracker) {
let newCount = 0
for (const content of contents) {
if (!Array.isArray(content.parts)) continue
for (const part of content.parts) {
if (part.functionResponse) {
const funcName = part.functionResponse.name?.toLowerCase() || 'unknown'
const pseudoId = `gemini:${funcName}:${tracker.seenToolResultIds.size}`
if (!tracker.seenToolResultIds.has(pseudoId)) {
tracker.seenToolResultIds.add(pseudoId)
newCount++
if (funcName !== 'prune') {
tracker.skipNextIdle = false
}
}
}
}
}
tracker.toolResultCount += newCount
return newCount
},
appendNudge(contents, nudgeText) {
contents.push({ role: 'user', parts: [{ text: nudgeText }] })
}
}

export function injectNudgeGemini(contents: any[], tracker: ToolTracker, nudgeText: string, freq: number): boolean {
return injectNudgeCore(contents, tracker, nudgeText, freq, geminiAdapter)
}

/** Check if a Gemini content matches nudge text */
function isNudgeContentGemini(content: any, nudgeText: string): boolean {
if (Array.isArray(content.parts) && content.parts.length === 1) {
Expand Down Expand Up @@ -182,34 +133,6 @@ export function injectSynthGemini(contents: any[], instruction: string, nudgeTex
// OpenAI Responses API Format (body.input with type-based items)
// ============================================================================

const responsesAdapter: MessageFormatAdapter = {
countToolResults(input, tracker) {
let newCount = 0
for (const item of input) {
if (item.type === 'function_call_output' && item.call_id) {
const id = String(item.call_id).toLowerCase()
if (!tracker.seenToolResultIds.has(id)) {
tracker.seenToolResultIds.add(id)
newCount++
const toolName = item.name || tracker.getToolName?.(item.call_id)
if (toolName !== 'prune') {
tracker.skipNextIdle = false
}
}
}
}
tracker.toolResultCount += newCount
return newCount
},
appendNudge(input, nudgeText) {
input.push({ type: 'message', role: 'user', content: nudgeText })
}
}

export function injectNudgeResponses(input: any[], tracker: ToolTracker, nudgeText: string, freq: number): boolean {
return injectNudgeCore(input, tracker, nudgeText, freq, responsesAdapter)
}

/** Check if a Responses API item matches nudge text */
function isNudgeItemResponses(item: any, nudgeText: string): boolean {
if (typeof item.content === 'string') {
Expand Down
Loading