diff --git a/src/backends/claude-code/contextFiles.ts b/src/backends/claude-code/contextFiles.ts index 2422947c..fe52e34f 100644 --- a/src/backends/claude-code/contextFiles.ts +++ b/src/backends/claude-code/contextFiles.ts @@ -1,191 +1,11 @@ /** - * Context file offloading for Claude Code backend. - * - * When context injections are too large to embed inline in the prompt, - * this module writes them to files and generates instructions for Claude - * to read them on-demand using its built-in Read tool. + * Re-export shim — implementation moved to shared module. + * Kept for backward compatibility. */ -import { mkdir, rm, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; +export { + buildInlineContextSection, + cleanupContextFiles, + offloadLargeContext, +} from '../shared/contextFiles.js'; -import { CONTEXT_OFFLOAD_CONFIG } from '../../config/claudeCodeConfig.js'; -import { estimateTokens } from '../../config/reviewConfig.js'; -import { logger } from '../../utils/logging.js'; -import type { ContextInjection } from '../types.js'; - -/** - * Metadata about an offloaded context file. - */ -export interface OffloadedFile { - /** Relative path from repo root, e.g. '.cascade/context/pr-diff.txt' */ - relativePath: string; - /** Original description of this context */ - description: string; - /** Estimated token count of the content */ - tokens: number; -} - -/** - * Result of context offloading. - */ -export interface ContextOffloadResult { - /** Context injections small enough to embed inline */ - inlineInjections: ContextInjection[]; - /** Files that were written for large context */ - offloadedFiles: OffloadedFile[]; - /** Instructions for Claude to read the offloaded files */ - instructions: string; -} - -/** - * Convert a description string into a safe filename. - * Includes index suffix to guarantee uniqueness within a batch. - */ -function slugify(description: string, index: number): string { - const base = description - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 40); // Shorter to make room for index - - // Always append index for guaranteed uniqueness within this batch - return `${base || 'context'}-${index}`; -} - -/** - * Generate instructions for Claude to read offloaded context files. - */ -function generateReadInstructions(files: OffloadedFile[]): string { - if (files.length === 0) return ''; - - const lines = [ - '## Context Files', - '', - 'The following context has been saved to files to avoid exceeding prompt limits.', - 'Use the Read tool to access them as needed:', - '', - ]; - - for (const file of files) { - lines.push( - `- \`${file.relativePath}\` — ${file.description} (~${file.tokens.toLocaleString()} tokens)`, - ); - } - - lines.push(''); - lines.push('Read these files as needed for your task. For review tasks, start with the PR diff.'); - - return lines.join('\n'); -} - -/** - * Offload large context injections to files. - * - * Small context (below threshold) is kept inline. - * Large context is written to .cascade/context/ and Claude is instructed to read it. - * - * @param repoDir - Repository directory where context files will be written - * @param injections - Context injections to process - * @returns Result with inline context, offloaded files, and instructions - */ -export async function offloadLargeContext( - repoDir: string, - injections: ContextInjection[], -): Promise { - if (!CONTEXT_OFFLOAD_CONFIG.enabled) { - return { - inlineInjections: injections, - offloadedFiles: [], - instructions: '', - }; - } - - const inlineInjections: ContextInjection[] = []; - const offloadedFiles: OffloadedFile[] = []; - const contextDir = join(repoDir, CONTEXT_OFFLOAD_CONFIG.contextDir); - let dirCreated = false; - - for (let i = 0; i < injections.length; i++) { - const injection = injections[i]; - const tokens = estimateTokens(injection.result); - - if (tokens < CONTEXT_OFFLOAD_CONFIG.inlineThreshold) { - inlineInjections.push(injection); - } else { - // Create context directory on first offload - if (!dirCreated) { - await mkdir(contextDir, { recursive: true }); - dirCreated = true; - } - - // Generate unique filename from description (with index for uniqueness) - const slug = slugify(injection.description, i); - const filename = `${slug}.txt`; - const filepath = join(contextDir, filename); - // Use forward slashes for consistent paths in instructions (works on all platforms) - const relativePath = `${CONTEXT_OFFLOAD_CONFIG.contextDir}/${filename}`; - - await writeFile(filepath, injection.result, 'utf-8'); - - offloadedFiles.push({ - relativePath, - description: injection.description, - tokens, - }); - - logger.info('Context offloaded to file', { - description: injection.description, - tokens, - path: relativePath, - }); - } - } - - const instructions = generateReadInstructions(offloadedFiles); - - if (offloadedFiles.length > 0) { - logger.info('Context offload summary', { - inlineCount: inlineInjections.length, - offloadedCount: offloadedFiles.length, - totalOffloadedTokens: offloadedFiles.reduce((sum, f) => sum + f.tokens, 0), - }); - } - - return { - inlineInjections, - offloadedFiles, - instructions, - }; -} - -/** - * Clean up context files after agent execution. - * - * Removes the .cascade/context/ directory and all its contents. - * - * @param repoDir - Repository directory - */ -export async function cleanupContextFiles(repoDir: string): Promise { - const contextDir = join(repoDir, CONTEXT_OFFLOAD_CONFIG.contextDir); - try { - await rm(contextDir, { recursive: true, force: true }); - logger.debug('Cleaned up context files', { contextDir }); - } catch { - // Ignore errors (directory might not exist) - } -} - -/** - * Build the inline context section for the prompt. - */ -export function buildInlineContextSection(injections: ContextInjection[]): string { - if (injections.length === 0) return ''; - - let section = '\n\n## Pre-loaded Context\n'; - for (const injection of injections) { - section += `\n### ${injection.description} (${injection.toolName})\n`; - section += `Parameters: ${JSON.stringify(injection.params)}\n`; - section += `\`\`\`\n${injection.result}\n\`\`\`\n`; - } - return section; -} +export type { ContextOffloadResult, OffloadedFile } from '../shared/contextFiles.js'; diff --git a/src/backends/contextFiles.ts b/src/backends/contextFiles.ts index 75c5f51b..9007456f 100644 --- a/src/backends/contextFiles.ts +++ b/src/backends/contextFiles.ts @@ -2,6 +2,6 @@ export { buildInlineContextSection, cleanupContextFiles, offloadLargeContext, -} from './claude-code/contextFiles.js'; +} from './shared/contextFiles.js'; -export type { ContextOffloadResult, OffloadedFile } from './claude-code/contextFiles.js'; +export type { ContextOffloadResult, OffloadedFile } from './shared/contextFiles.js'; diff --git a/src/backends/nativeTools.ts b/src/backends/nativeTools.ts index c1b0033c..fb64c9a8 100644 --- a/src/backends/nativeTools.ts +++ b/src/backends/nativeTools.ts @@ -1,115 +1,11 @@ -import { buildInlineContextSection, offloadLargeContext } from './contextFiles.js'; -import type { ContextInjection, ToolManifest } from './types.js'; - -const NATIVE_TOOL_EXECUTION_RULES = `## Native Tool Execution Rules - -You are operating in a native-tool environment, not a gadget/function-call environment. - -- Never write pseudo tool calls such as \`[tool_call: ...]\`, \`ReadFile(...)\`, \`RipGrep(...)\`, \`Tmux(...)\`, \`CreatePR(...)\`, or similar function-call text in your assistant response. -- Use actual OpenCode/Codex tool invocations instead: - - use built-in file/search tools or the shell tool for repository exploration - - use the edit tool for file modifications - - use the shell tool for all \`cascade-tools ...\`, \`git ...\`, \`rg ...\`, \`fd ...\`, test, lint, and build commands -- When the task instructions mention gadget names like \`CreatePR\`, \`PostComment\`, \`UpdateChecklistItem\`, \`Finish\`, \`ReadWorkItem\`, \`TodoUpsert\`, or \`TodoUpdateStatus\`, treat that as a request to run the equivalent real command or tool action, not to print the gadget name. -- If you catch yourself composing a pseudo tool call in plain text, stop and use the real tool instead.`; - -/** - * Format a single CLI parameter for tool guidance documentation. - */ -function formatParam( - key: string, - schema: { type: string; required?: boolean; default?: unknown; description?: string }, -): string { - let result: string; - if (schema.type === 'array') { - const singular = key.replace(/s$/, ''); - result = schema.required - ? ` --${singular} (repeatable)` - : ` [--${singular} (repeatable)]`; - } else if (schema.type === 'boolean') { - result = schema.default === true ? ` [--no-${key}]` : ` [--${key}]`; - } else { - result = schema.required ? ` --${key} <${schema.type}>` : ` [--${key} <${schema.type}>]`; - } - if (schema.description) { - result += ` # ${schema.description}`; - } - return result; -} - /** - * Build prompt guidance for CASCADE-specific CLI tools. - * Native-tool engines invoke these via shell commands. + * Re-export shim — implementation moved to shared module. + * Kept for backward compatibility. */ -export function buildToolGuidance(tools: ToolManifest[]): string { - if (tools.length === 0) return ''; - - let guidance = '## CASCADE Tools\n\n'; - guidance += 'Use the shell tool to invoke these CASCADE-specific commands.\n'; - guidance += 'All commands output JSON. Parse the output to extract results.\n\n'; - guidance += - '**CRITICAL**: You MUST use these cascade-tools commands for all PM (Trello/JIRA), SCM (GitHub), and session operations. ' + - 'Do NOT use `gh` CLI or other tools directly — native-tool engine runs block `gh`, and cascade-tools handle authentication, push, and ' + - 'state tracking that raw CLI tools do not. For example, `cascade-tools scm create-pr` pushes ' + - 'the branch AND creates the PR atomically.\n\n'; - - for (const tool of tools) { - guidance += `### ${tool.name}\n`; - guidance += `${tool.description}\n`; - guidance += `\`\`\`bash\n${tool.cliCommand}`; - - for (const [key, schema] of Object.entries(tool.parameters)) { - guidance += formatParam(key, schema as { type: string; required?: boolean }); - } - - guidance += '\n```\n\n'; - } - - return guidance; -} +export { + buildSystemPrompt, + buildTaskPrompt, + buildToolGuidance, +} from './shared/nativeToolPrompts.js'; -export interface BuildTaskPromptResult { - prompt: string; - hasOffloadedContext: boolean; -} - -/** - * Build the task prompt with pre-fetched context injections. - * Large context is offloaded to files that the engine can read on demand. - */ -export async function buildTaskPrompt( - taskPrompt: string, - contextInjections: ContextInjection[], - repoDir: string, -): Promise { - let prompt = taskPrompt; - - if (contextInjections.length === 0) { - return { prompt, hasOffloadedContext: false }; - } - - const { inlineInjections, offloadedFiles, instructions } = await offloadLargeContext( - repoDir, - contextInjections, - ); - - prompt += buildInlineContextSection(inlineInjections); - - if (instructions) { - prompt += `\n\n${instructions}`; - } - - return { - prompt, - hasOffloadedContext: offloadedFiles.length > 0, - }; -} - -/** - * Build the system prompt by combining CASCADE's agent prompt with tool guidance. - */ -export function buildSystemPrompt(systemPrompt: string, tools: ToolManifest[]): string { - const toolGuidance = buildToolGuidance(tools); - const promptWithRules = `${NATIVE_TOOL_EXECUTION_RULES}\n\n${systemPrompt}`; - return toolGuidance ? `${promptWithRules}\n\n${toolGuidance}` : promptWithRules; -} +export type { BuildTaskPromptResult } from './shared/nativeToolPrompts.js'; diff --git a/src/backends/shared/contextFiles.ts b/src/backends/shared/contextFiles.ts new file mode 100644 index 00000000..5cca8247 --- /dev/null +++ b/src/backends/shared/contextFiles.ts @@ -0,0 +1,191 @@ +/** + * Context file offloading for native-tool backends. + * + * When context injections are too large to embed inline in the prompt, + * this module writes them to files and generates instructions for the agent + * to read them on-demand using its built-in Read tool. + */ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { CONTEXT_OFFLOAD_CONFIG } from '../../config/claudeCodeConfig.js'; +import { estimateTokens } from '../../config/reviewConfig.js'; +import { logger } from '../../utils/logging.js'; +import type { ContextInjection } from '../types.js'; + +/** + * Metadata about an offloaded context file. + */ +export interface OffloadedFile { + /** Relative path from repo root, e.g. '.cascade/context/pr-diff.txt' */ + relativePath: string; + /** Original description of this context */ + description: string; + /** Estimated token count of the content */ + tokens: number; +} + +/** + * Result of context offloading. + */ +export interface ContextOffloadResult { + /** Context injections small enough to embed inline */ + inlineInjections: ContextInjection[]; + /** Files that were written for large context */ + offloadedFiles: OffloadedFile[]; + /** Instructions for the agent to read the offloaded files */ + instructions: string; +} + +/** + * Convert a description string into a safe filename. + * Includes index suffix to guarantee uniqueness within a batch. + */ +function slugify(description: string, index: number): string { + const base = description + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); // Shorter to make room for index + + // Always append index for guaranteed uniqueness within this batch + return `${base || 'context'}-${index}`; +} + +/** + * Generate instructions for the agent to read offloaded context files. + */ +function generateReadInstructions(files: OffloadedFile[]): string { + if (files.length === 0) return ''; + + const lines = [ + '## Context Files', + '', + 'The following context has been saved to files to avoid exceeding prompt limits.', + 'Use the Read tool to access them as needed:', + '', + ]; + + for (const file of files) { + lines.push( + `- \`${file.relativePath}\` — ${file.description} (~${file.tokens.toLocaleString()} tokens)`, + ); + } + + lines.push(''); + lines.push('Read these files as needed for your task. For review tasks, start with the PR diff.'); + + return lines.join('\n'); +} + +/** + * Offload large context injections to files. + * + * Small context (below threshold) is kept inline. + * Large context is written to .cascade/context/ and the agent is instructed to read it. + * + * @param repoDir - Repository directory where context files will be written + * @param injections - Context injections to process + * @returns Result with inline context, offloaded files, and instructions + */ +export async function offloadLargeContext( + repoDir: string, + injections: ContextInjection[], +): Promise { + if (!CONTEXT_OFFLOAD_CONFIG.enabled) { + return { + inlineInjections: injections, + offloadedFiles: [], + instructions: '', + }; + } + + const inlineInjections: ContextInjection[] = []; + const offloadedFiles: OffloadedFile[] = []; + const contextDir = join(repoDir, CONTEXT_OFFLOAD_CONFIG.contextDir); + let dirCreated = false; + + for (let i = 0; i < injections.length; i++) { + const injection = injections[i]; + const tokens = estimateTokens(injection.result); + + if (tokens < CONTEXT_OFFLOAD_CONFIG.inlineThreshold) { + inlineInjections.push(injection); + } else { + // Create context directory on first offload + if (!dirCreated) { + await mkdir(contextDir, { recursive: true }); + dirCreated = true; + } + + // Generate unique filename from description (with index for uniqueness) + const slug = slugify(injection.description, i); + const filename = `${slug}.txt`; + const filepath = join(contextDir, filename); + // Use forward slashes for consistent paths in instructions (works on all platforms) + const relativePath = `${CONTEXT_OFFLOAD_CONFIG.contextDir}/${filename}`; + + await writeFile(filepath, injection.result, 'utf-8'); + + offloadedFiles.push({ + relativePath, + description: injection.description, + tokens, + }); + + logger.info('Context offloaded to file', { + description: injection.description, + tokens, + path: relativePath, + }); + } + } + + const instructions = generateReadInstructions(offloadedFiles); + + if (offloadedFiles.length > 0) { + logger.info('Context offload summary', { + inlineCount: inlineInjections.length, + offloadedCount: offloadedFiles.length, + totalOffloadedTokens: offloadedFiles.reduce((sum, f) => sum + f.tokens, 0), + }); + } + + return { + inlineInjections, + offloadedFiles, + instructions, + }; +} + +/** + * Clean up context files after agent execution. + * + * Removes the .cascade/context/ directory and all its contents. + * + * @param repoDir - Repository directory + */ +export async function cleanupContextFiles(repoDir: string): Promise { + const contextDir = join(repoDir, CONTEXT_OFFLOAD_CONFIG.contextDir); + try { + await rm(contextDir, { recursive: true, force: true }); + logger.debug('Cleaned up context files', { contextDir }); + } catch { + // Ignore errors (directory might not exist) + } +} + +/** + * Build the inline context section for the prompt. + */ +export function buildInlineContextSection(injections: ContextInjection[]): string { + if (injections.length === 0) return ''; + + let section = '\n\n## Pre-loaded Context\n'; + for (const injection of injections) { + section += `\n### ${injection.description} (${injection.toolName})\n`; + section += `Parameters: ${JSON.stringify(injection.params)}\n`; + section += `\`\`\`\n${injection.result}\n\`\`\`\n`; + } + return section; +} diff --git a/src/backends/shared/nativeToolPrompts.ts b/src/backends/shared/nativeToolPrompts.ts new file mode 100644 index 00000000..0f911d39 --- /dev/null +++ b/src/backends/shared/nativeToolPrompts.ts @@ -0,0 +1,115 @@ +import type { ContextInjection, ToolManifest } from '../types.js'; +import { buildInlineContextSection, offloadLargeContext } from './contextFiles.js'; + +const NATIVE_TOOL_EXECUTION_RULES = `## Native Tool Execution Rules + +You are operating in a native-tool environment, not a gadget/function-call environment. + +- Never write pseudo tool calls such as \`[tool_call: ...]\`, \`ReadFile(...)\`, \`RipGrep(...)\`, \`Tmux(...)\`, \`CreatePR(...)\`, or similar function-call text in your assistant response. +- Use actual OpenCode/Codex tool invocations instead: + - use built-in file/search tools or the shell tool for repository exploration + - use the edit tool for file modifications + - use the shell tool for all \`cascade-tools ...\`, \`git ...\`, \`rg ...\`, \`fd ...\`, test, lint, and build commands +- When the task instructions mention gadget names like \`CreatePR\`, \`PostComment\`, \`UpdateChecklistItem\`, \`Finish\`, \`ReadWorkItem\`, \`TodoUpsert\`, or \`TodoUpdateStatus\`, treat that as a request to run the equivalent real command or tool action, not to print the gadget name. +- If you catch yourself composing a pseudo tool call in plain text, stop and use the real tool instead.`; + +/** + * Format a single CLI parameter for tool guidance documentation. + */ +function formatParam( + key: string, + schema: { type: string; required?: boolean; default?: unknown; description?: string }, +): string { + let result: string; + if (schema.type === 'array') { + const singular = key.replace(/s$/, ''); + result = schema.required + ? ` --${singular} (repeatable)` + : ` [--${singular} (repeatable)]`; + } else if (schema.type === 'boolean') { + result = schema.default === true ? ` [--no-${key}]` : ` [--${key}]`; + } else { + result = schema.required ? ` --${key} <${schema.type}>` : ` [--${key} <${schema.type}>]`; + } + if (schema.description) { + result += ` # ${schema.description}`; + } + return result; +} + +/** + * Build prompt guidance for CASCADE-specific CLI tools. + * Native-tool engines invoke these via shell commands. + */ +export function buildToolGuidance(tools: ToolManifest[]): string { + if (tools.length === 0) return ''; + + let guidance = '## CASCADE Tools\n\n'; + guidance += 'Use the shell tool to invoke these CASCADE-specific commands.\n'; + guidance += 'All commands output JSON. Parse the output to extract results.\n\n'; + guidance += + '**CRITICAL**: You MUST use these cascade-tools commands for all PM (Trello/JIRA), SCM (GitHub), and session operations. ' + + 'Do NOT use `gh` CLI or other tools directly — native-tool engine runs block `gh`, and cascade-tools handle authentication, push, and ' + + 'state tracking that raw CLI tools do not. For example, `cascade-tools scm create-pr` pushes ' + + 'the branch AND creates the PR atomically.\n\n'; + + for (const tool of tools) { + guidance += `### ${tool.name}\n`; + guidance += `${tool.description}\n`; + guidance += `\`\`\`bash\n${tool.cliCommand}`; + + for (const [key, schema] of Object.entries(tool.parameters)) { + guidance += formatParam(key, schema as { type: string; required?: boolean }); + } + + guidance += '\n```\n\n'; + } + + return guidance; +} + +export interface BuildTaskPromptResult { + prompt: string; + hasOffloadedContext: boolean; +} + +/** + * Build the task prompt with pre-fetched context injections. + * Large context is offloaded to files that the engine can read on demand. + */ +export async function buildTaskPrompt( + taskPrompt: string, + contextInjections: ContextInjection[], + repoDir: string, +): Promise { + let prompt = taskPrompt; + + if (contextInjections.length === 0) { + return { prompt, hasOffloadedContext: false }; + } + + const { inlineInjections, offloadedFiles, instructions } = await offloadLargeContext( + repoDir, + contextInjections, + ); + + prompt += buildInlineContextSection(inlineInjections); + + if (instructions) { + prompt += `\n\n${instructions}`; + } + + return { + prompt, + hasOffloadedContext: offloadedFiles.length > 0, + }; +} + +/** + * Build the system prompt by combining CASCADE's agent prompt with tool guidance. + */ +export function buildSystemPrompt(systemPrompt: string, tools: ToolManifest[]): string { + const toolGuidance = buildToolGuidance(tools); + const promptWithRules = `${NATIVE_TOOL_EXECUTION_RULES}\n\n${systemPrompt}`; + return toolGuidance ? `${promptWithRules}\n\n${toolGuidance}` : promptWithRules; +} diff --git a/tests/unit/backends/claude-code-contextFiles.test.ts b/tests/unit/backends/claude-code-contextFiles.test.ts index b4cfb3cd..08a41384 100644 --- a/tests/unit/backends/claude-code-contextFiles.test.ts +++ b/tests/unit/backends/claude-code-contextFiles.test.ts @@ -21,7 +21,7 @@ import { buildInlineContextSection, cleanupContextFiles, offloadLargeContext, -} from '../../../src/backends/claude-code/contextFiles.js'; +} from '../../../src/backends/shared/contextFiles.js'; import type { ContextInjection } from '../../../src/backends/types.js'; import { CONTEXT_OFFLOAD_CONFIG } from '../../../src/config/claudeCodeConfig.js';