diff --git a/.agents/types/tools.ts b/.agents/types/tools.ts index f154211fe7..70d9a894f0 100644 --- a/.agents/types/tools.ts +++ b/.agents/types/tools.ts @@ -1157,12 +1157,13 @@ export interface WriteFileParams { * Parameters for write_audit_findings tool */ export interface WriteAuditFindingsParams { - /** Existing durable audit session slug under .agents/sessions/. */ + /** Existing durable audit session slug under .agents/sessions/. Accepts only a short identifier token: 1 to 100 characters of letters, digits, dot, underscore, or dash, and neither `.` nor `..` on its own. */ sessionSlug: string - /** Unique shard identifier used as the findings filename. */ + /** Unique shard identifier used as the findings filename. Accepts only a short identifier token: 1 to 100 characters of letters, digits, dot, underscore, or dash, and neither `.` nor `..` on its own. */ shardId: string - /** Exact snapshotId returned by inspect_codebase_structure. Required for a directly composable structuralReceipt; omitted only for legacy callers. */ + /** Exact snapshotId returned by inspect_codebase_structure, such as its 64-character sha256 digest. Required for a directly composable structuralReceipt; omitted only for legacy callers. Accepts only a short identifier token: 1 to 100 characters of letters, digits, dot, underscore, or dash, and neither `.` nor `..` on its own. When snapshotId and coverage.domains are both present the call receives a structuralReceipt, so coverage.subsystemIds and coverage.files must each name at least one entry: evaluate_audit_coverage rejects a receipt whose subsystem_ids or files list is empty. */ snapshotId?: string + /** Each findings entry rejects control and Unicode format characters in title, risk, fix, and evidence — NUL, any other control character, and the U+2028/U+2029 line separators — while still accepting tabs and line breaks in that prose. findings[].path is a location rather than prose, so it must be a single-line value with none of those characters and no tabs or line breaks; it is trimmed, and the trimmed value is the one rendered into the finding heading. */ findings: { severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' domain: @@ -1182,10 +1183,15 @@ export interface WriteAuditFindingsParams { fix: string evidence: string }[] + /** Every coverage list must name each entry at most once: a repeated file, subsystemId, featureId, or domain is rejected rather than counted twice. Entries are compared after trimming surrounding whitespace, and the trimmed value is what reaches the artifact and the receipt, so two spellings that differ only in whitespace are the same entry. Every coverage files, subsystemIds, and featureIds entry must be a single-line value: tabs, carriage returns, newlines, NUL, any other control or Unicode format character, and the U+2028/U+2029 line separators are rejected. Entries are trimmed, and the trimmed value is the one uniqueness is judged on. */ coverage: { + /** See the coverage description for the single-line hygiene rule, which applies to this list too. See the coverage description for the uniqueness rule, which applies to this list too. */ subsystemIds: string[] + /** See the coverage description for the single-line hygiene rule, which applies to this list too. See the coverage description for the uniqueness rule, which applies to this list too. */ featureIds: string[] + /** See the coverage description for the single-line hygiene rule, which applies to this list too. See the coverage description for the uniqueness rule, which applies to this list too. */ files: string[] + /** coverage.domains accepts canonical domain ids only, so use api-contract there: the legacy api-abi alias is accepted only in findings[].domain. When coverage.domains is present it must name at least one domain: an empty list is rejected rather than treated as an omitted field. See the coverage description for the uniqueness rule, which applies to this list too. */ domains?: ( | 'security' | 'correctness' @@ -1197,6 +1203,7 @@ export interface WriteAuditFindingsParams { | 'api-contract' )[] } + /** Set noIssuesFound=true exactly when findings is empty and false whenever findings is non-empty; any other combination is rejected. */ noIssuesFound?: boolean } diff --git a/agents/types/tools.ts b/agents/types/tools.ts index f154211fe7..70d9a894f0 100644 --- a/agents/types/tools.ts +++ b/agents/types/tools.ts @@ -1157,12 +1157,13 @@ export interface WriteFileParams { * Parameters for write_audit_findings tool */ export interface WriteAuditFindingsParams { - /** Existing durable audit session slug under .agents/sessions/. */ + /** Existing durable audit session slug under .agents/sessions/. Accepts only a short identifier token: 1 to 100 characters of letters, digits, dot, underscore, or dash, and neither `.` nor `..` on its own. */ sessionSlug: string - /** Unique shard identifier used as the findings filename. */ + /** Unique shard identifier used as the findings filename. Accepts only a short identifier token: 1 to 100 characters of letters, digits, dot, underscore, or dash, and neither `.` nor `..` on its own. */ shardId: string - /** Exact snapshotId returned by inspect_codebase_structure. Required for a directly composable structuralReceipt; omitted only for legacy callers. */ + /** Exact snapshotId returned by inspect_codebase_structure, such as its 64-character sha256 digest. Required for a directly composable structuralReceipt; omitted only for legacy callers. Accepts only a short identifier token: 1 to 100 characters of letters, digits, dot, underscore, or dash, and neither `.` nor `..` on its own. When snapshotId and coverage.domains are both present the call receives a structuralReceipt, so coverage.subsystemIds and coverage.files must each name at least one entry: evaluate_audit_coverage rejects a receipt whose subsystem_ids or files list is empty. */ snapshotId?: string + /** Each findings entry rejects control and Unicode format characters in title, risk, fix, and evidence — NUL, any other control character, and the U+2028/U+2029 line separators — while still accepting tabs and line breaks in that prose. findings[].path is a location rather than prose, so it must be a single-line value with none of those characters and no tabs or line breaks; it is trimmed, and the trimmed value is the one rendered into the finding heading. */ findings: { severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' domain: @@ -1182,10 +1183,15 @@ export interface WriteAuditFindingsParams { fix: string evidence: string }[] + /** Every coverage list must name each entry at most once: a repeated file, subsystemId, featureId, or domain is rejected rather than counted twice. Entries are compared after trimming surrounding whitespace, and the trimmed value is what reaches the artifact and the receipt, so two spellings that differ only in whitespace are the same entry. Every coverage files, subsystemIds, and featureIds entry must be a single-line value: tabs, carriage returns, newlines, NUL, any other control or Unicode format character, and the U+2028/U+2029 line separators are rejected. Entries are trimmed, and the trimmed value is the one uniqueness is judged on. */ coverage: { + /** See the coverage description for the single-line hygiene rule, which applies to this list too. See the coverage description for the uniqueness rule, which applies to this list too. */ subsystemIds: string[] + /** See the coverage description for the single-line hygiene rule, which applies to this list too. See the coverage description for the uniqueness rule, which applies to this list too. */ featureIds: string[] + /** See the coverage description for the single-line hygiene rule, which applies to this list too. See the coverage description for the uniqueness rule, which applies to this list too. */ files: string[] + /** coverage.domains accepts canonical domain ids only, so use api-contract there: the legacy api-abi alias is accepted only in findings[].domain. When coverage.domains is present it must name at least one domain: an empty list is rejected rather than treated as an omitted field. See the coverage description for the uniqueness rule, which applies to this list too. */ domains?: ( | 'security' | 'correctness' @@ -1197,6 +1203,7 @@ export interface WriteAuditFindingsParams { | 'api-contract' )[] } + /** Set noIssuesFound=true exactly when findings is empty and false whenever findings is non-empty; any other combination is rejected. */ noIssuesFound?: boolean } diff --git a/cli/src/data/initial-agent-type-sources.generated.ts b/cli/src/data/initial-agent-type-sources.generated.ts index beefd9b774..3b339141b7 100644 --- a/cli/src/data/initial-agent-type-sources.generated.ts +++ b/cli/src/data/initial-agent-type-sources.generated.ts @@ -6,6 +6,6 @@ export const agentDefinitionSource = "/**\n * Openbuff Agent Type Definitions\n *\n * This file provides TypeScript type definitions for creating custom Openbuff agents.\n * Import these types in your agent files to get full type safety and IntelliSense.\n *\n * Usage in .agents/your-agent.ts:\n * import { AgentDefinition, ToolName, ModelName } from './types/agent-definition'\n *\n * const definition: AgentDefinition = {\n * // ... your agent configuration with full type safety ...\n * }\n *\n * export default definition\n */\n\n// ============================================================================\n// Agent Definition and Utility Types\n// ============================================================================\n\nexport interface AgentDefinition {\n /** Unique identifier for this agent. Must contain only lowercase letters, numbers, and hyphens, e.g. 'code-reviewer' */\n id: string\n\n /** Version string (if not provided, will default to '0.0.1' and be bumped on each publish) */\n version?: string\n\n /** Publisher ID for the agent. Must be provided if you want to publish the agent. */\n publisher?: string\n\n /** Human-readable name for the agent */\n displayName: string\n\n /**\n * AI model to use for this agent. Can be any model in OpenRouter: https://openrouter.ai/models\n *\n * Optional: if omitted, the model is resolved entirely from the user's openbuff.json via\n * `agents[agentId]` or `defaultModel`. An error is thrown at runtime if neither is configured.\n */\n model?: ModelName\n\n /**\n * Optional wall-clock timeout in milliseconds for a single execution of this\n * agent as a subagent. When set, executeSubagent uses this as the deadline\n * (overridable per-spawn via spawn_agents' timeout_seconds). Undefined falls\n * back to the shared DEFAULT_SUBAGENT_TIMEOUT_MS, which is -1 (disabled): by\n * default there is no wall-clock timeout, so long-running agents run to\n * completion. Set a positive value to opt this agent into a wall-clock bound.\n */\n defaultTimeoutMs?: number\n\n /** Maximum subagent nesting depth. Defaults to the runtime limit. */\n maxSpawnDepth?: number\n\n /**\n * https://openrouter.ai/docs/use-cases/reasoning-tokens\n * One of `max_tokens` or `effort` is required.\n * If `exclude` is true, reasoning will be removed from the response. Default is false.\n */\n reasoningOptions?: {\n enabled?: boolean\n exclude?: boolean\n } & (\n | {\n max_tokens: number\n }\n | {\n effort: 'high' | 'medium' | 'low' | 'minimal' | 'none'\n }\n )\n\n /**\n * Provider routing options for OpenRouter.\n * Controls which providers to use and fallback behavior.\n * See https://openrouter.ai/docs/features/provider-routing\n */\n providerOptions?: {\n /**\n * List of provider slugs to try in order (e.g. [\"anthropic\", \"openai\"])\n */\n order?: string[]\n /**\n * Whether to allow backup providers when primary is unavailable (default: true)\n */\n allow_fallbacks?: boolean\n /**\n * Only use providers that support all parameters in your request (default: false)\n */\n require_parameters?: boolean\n /**\n * Control whether to use providers that may store data\n */\n data_collection?: 'allow' | 'deny'\n /**\n * List of provider slugs to allow for this request\n */\n only?: string[]\n /**\n * List of provider slugs to skip for this request\n */\n ignore?: string[]\n /**\n * List of quantization levels to filter by (e.g. [\"int4\", \"int8\"])\n */\n quantizations?: Array<\n | 'int4'\n | 'int8'\n | 'fp4'\n | 'fp6'\n | 'fp8'\n | 'fp16'\n | 'bf16'\n | 'fp32'\n | 'unknown'\n >\n /**\n * Sort providers by price, throughput, or latency\n */\n sort?: 'price' | 'throughput' | 'latency'\n /**\n * Maximum pricing you want to pay for this request\n */\n max_price?: {\n prompt?: number | string\n completion?: number | string\n image?: number | string\n audio?: number | string\n request?: number | string\n }\n }\n\n /**\n * Optional per-run cost cap in US cents. When set, the agent runtime\n * enforces this as a hard spend ceiling — the turn ends if cumulative\n * creditsUsed exceeds it. Useful for BYOK configurations to guard\n * against runaway spend. Undefined = no cap.\n */\n maxCostCents?: number\n\n /**\n * Optional per-step input token cap. When set, the agent runtime ends\n * the turn if a single step's total input tokens exceed this threshold.\n * Undefined = no cap.\n */\n maxTokensPerTurn?: number\n\n // ============================================================================\n // Tools and Subagents\n // ============================================================================\n\n /** MCP servers by name. Names cannot contain `/`. */\n mcpServers?: Record\n\n /**\n * Tools this agent can use.\n *\n * By default, all tools are available from any specified MCP server. In\n * order to limit the tools from a specific MCP server, add the tool name(s)\n * in the format `'mcpServerName/toolName1'`, `'mcpServerName/toolName2'`,\n * etc.\n */\n toolNames?: (ToolName | (string & {}))[]\n\n /** Tools callable only from `handleSteps`; these are hidden from the model. */\n programmaticToolNames?: (ToolName | (string & {}))[]\n /**\n * Controls whether every spawnable agent is exposed as a separate native\n * tool (`direct`) or only through the generic `spawn_agents` tool\n * (`generic`). Defaults to `direct` for compatibility.\n */\n spawnableAgentToolMode?: 'direct' | 'generic'\n\n /** Enforced shell capability for this agent. Defaults to workspace-write. */\n terminalPermissionProfile?:\n | 'read-only'\n | 'librarian-read-only'\n | 'git-commit'\n | 'dependency-mutation'\n | 'validation-diagnosis'\n | 'tmux-test'\n | 'workspace-write'\n | 'full-access'\n /** Runtime-enforced project-relative glob allowlists for filesystem tools. */\n filesystemScope?: {\n read?: string[]\n write?: string[]\n }\n programmaticConfig?: Record\n\n /** Other agents this agent can spawn, like 'openbuff/file-picker@0.0.1'.\n *\n * Use the fully qualified agent id from the agent store, including publisher and version, for example: 'openbuff/file-picker@0.0.1'\n * (publisher and version are required!)\n *\n * Or, use the agent id from a local agent file in your .agents directory: 'file-picker'.\n */\n spawnableAgents?: string[]\n\n // ============================================================================\n // Input and Output\n // ============================================================================\n\n /** The input schema required to spawn the agent. Provide a prompt string and/or a params object or none.\n * 80% of the time you want just a prompt string with a description:\n * inputSchema: {\n * prompt: { type: 'string', description: 'A description of what info would be helpful to the agent' }\n * }\n */\n inputSchema?: {\n prompt?: { type: 'string'; description?: string }\n params?: JsonObjectSchema\n }\n\n /** How the agent should output a response to its parent (defaults to 'last_message')\n *\n * last_message: The last message from the agent, typically after using tools.\n *\n * all_messages: All messages from the agent, including tool calls and results.\n *\n * structured_output: Make the agent output a JSON object. Can be used with outputSchema or without if you want freeform json output.\n */\n outputMode?: 'last_message' | 'all_messages' | 'structured_output'\n\n /** JSON schema for structured output (when outputMode is 'structured_output') */\n outputSchema?: JsonObjectSchema\n\n // ============================================================================\n // Prompts\n // ============================================================================\n\n /** Prompt for when and why to spawn this agent. Include the main purpose and use cases.\n *\n * This field is key if the agent is intended to be spawned by other agents. */\n spawnerPrompt?: string\n\n /** Whether to include conversation history from the parent agent in context.\n *\n * Defaults to false.\n * Use this when the agent needs to know all the previous messages in the conversation.\n */\n includeMessageHistory?: boolean\n /** Bounded parent-history transfer policy. Defaults from includeMessageHistory. */\n messageHistoryMode?: 'none' | 'pinned' | 'full'\n /** Explicit capability for inline history-editor agents. Defaults to false. */\n propagateMessageHistoryChanges?: boolean\n\n /** Whether to append model reasoning chunks to this agent's message history.\n *\n * Defaults to false for better prompt-cache stability. Enable only when an\n * agent explicitly needs its hidden reasoning replayed on later turns.\n */\n includeReasoningInMessageHistory?: boolean\n\n /** Whether to inherit the parent agent's system prompt instead of using this agent's own systemPrompt.\n *\n * Defaults to false.\n * Use this when you want to enable prompt caching by preserving the same system prompt prefix.\n * Cannot be used together with the systemPrompt field.\n */\n inheritParentSystemPrompt?: boolean\n\n /** Background information for the agent. Fairly optional. Prefer using instructionsPrompt for agent instructions. */\n systemPrompt?: string\n\n /** Instructions for the agent.\n *\n * IMPORTANT: Updating this prompt is the best way to shape the agent's behavior.\n * This prompt is inserted after each user input. */\n instructionsPrompt?: string\n\n /** Prompt inserted at each agent step.\n *\n * Powerful for changing the agent's behavior, but usually not necessary for smart models.\n * Prefer instructionsPrompt for most instructions. */\n stepPrompt?: string\n\n // ============================================================================\n // Handle Steps\n // ============================================================================\n\n /** Programmatically step the agent forward and run tools.\n *\n * You can either yield:\n * - A tool call object with toolName and input properties.\n * - 'STEP' to run agent's model and generate one assistant message.\n * - 'STEP_ALL' to run the agent's model until it uses the end_turn tool or stops includes no tool calls in a message.\n *\n * Or use 'return' to end the turn.\n *\n * Example 1:\n * function* handleSteps({ agentState, prompt, params, logger }) {\n * logger.info('Starting file read process')\n * const { toolResult } = yield {\n * toolName: 'read_files',\n * input: { paths: ['file1.txt', 'file2.txt'] }\n * }\n * yield 'STEP_ALL'\n *\n * // Optionally do a post-processing step here...\n * logger.info('Files read successfully, setting output')\n * yield {\n * toolName: 'set_output',\n * input: {\n * output: 'The files were read successfully.',\n * },\n * }\n * }\n *\n * Example 2:\n * handleSteps: function* ({ agentState, prompt, params, logger }) {\n * while (true) {\n * logger.debug('Spawning thinker agent')\n * yield {\n * toolName: 'spawn_agents',\n * input: {\n * agents: [\n * {\n * agent_type: 'thinker',\n * prompt: 'Think deeply about the user request',\n * },\n * ],\n * },\n * }\n * const { stepsComplete } = yield 'STEP'\n * if (stepsComplete) break\n * }\n * }\n */\n handleSteps?: (context: AgentStepContext) => Generator<\n ToolCall | 'STEP' | 'STEP_ALL' | StepText | GenerateN,\n void,\n {\n agentState: AgentState\n toolResult: ToolResultOutput[] | undefined\n stepsComplete: boolean\n nResponses?: string[]\n }\n >\n}\n\n// ============================================================================\n// Supporting Types\n// ============================================================================\n\nexport interface AgentState {\n agentId: string\n runId: string\n parentId: string | undefined\n\n /** The agent's conversation history: messages from the user and the assistant. */\n messageHistory: Message[]\n\n /** The last value set by the set_output tool. This is a plain object or undefined if not set. */\n output: Record | undefined\n\n /** The system prompt for this agent. */\n systemPrompt: string\n\n /** The tool definitions for this agent. */\n toolDefinitions: Record<\n string,\n { description: string | undefined; inputSchema: {} }\n >\n\n /**\n * The token count from the Anthropic API.\n * This is updated on every agent step via the /api/v1/token-count endpoint.\n */\n contextTokenCount: number\n\n /** Context window resolved from the active model/provider, when known. */\n contextWindowTokens?: number\n\n /** Runtime-owned orchestrator state preserved independently of messages. */\n base2ActiveWork?: Record\n}\n\n/**\n * Context provided to handleSteps generator function\n */\nexport interface AgentStepContext {\n agentState: AgentState\n prompt?: string\n params?: Record\n logger: Logger\n config?: Record\n}\n\nexport type StepText = { type: 'STEP_TEXT'; text: string }\nexport type GenerateN = { type: 'GENERATE_N'; n: number }\n\n/**\n * Tool call object for handleSteps generator\n */\nexport type ToolCall = {\n [K in T]: {\n toolName: K\n input: GetToolParams\n includeToolCall?: boolean\n }\n}[T]\n\n// ============================================================================\n// Available Tools\n// ============================================================================\n\n/**\n * File operation tools\n */\nexport type FileEditingTools = 'read_files' | 'write_file' | 'str_replace'\n\n/**\n * Code analysis tools\n */\nexport type CodeAnalysisTools = 'code_search' | 'find_files' | 'read_files'\n\n/**\n * Terminal and system tools\n */\nexport type TerminalTools = 'run_terminal_command' | 'code_search'\n\n/**\n * Web and browser tools\n */\nexport type WebTools = 'web_search' | 'read_docs'\n\n/**\n * Agent management tools\n */\nexport type AgentTools = 'spawn_agents'\n\n/**\n * Output and control tools\n */\nexport type OutputTools = 'set_output'\n\n// ============================================================================\n// Available Models (see: https://openrouter.ai/models)\n// ============================================================================\n\n/**\n * AI models available for agents. Pick from our selection of recommended models or choose any model in OpenRouter.\n *\n * See available models at https://openrouter.ai/models\n */\nexport type ModelName =\n // Recommended Models\n\n // OpenAI\n | 'openai/gpt-5.5'\n | 'openai/gpt-5.4'\n | 'openai/gpt-5.4-mini'\n | 'openai/gpt-5.4-nano'\n | 'openai/gpt-5.3'\n | 'openai/gpt-5.3-codex'\n | 'openai/gpt-5.2'\n | 'openai/gpt-5.2-chat-latest'\n | 'openai/gpt-5.1'\n | 'openai/gpt-5.1-chat'\n\n // Anthropic\n | 'anthropic/claude-sonnet-4.6'\n | 'anthropic/claude-opus-4.7'\n | 'anthropic/claude-opus-4.6'\n | 'anthropic/claude-opus-4.5'\n | 'anthropic/claude-haiku-4.5'\n | 'anthropic/claude-sonnet-4.5'\n | 'anthropic/claude-opus-4.1'\n\n // Gemini\n | 'google/gemini-3.1-pro-preview'\n | 'google/gemini-3-pro-preview'\n | 'google/gemini-3-flash-preview'\n | 'google/gemini-3.1-flash-lite-preview'\n | 'google/gemini-2.5-pro'\n | 'google/gemini-2.5-flash'\n | 'google/gemini-2.5-flash-lite'\n\n // X-AI\n | 'x-ai/grok-4-fast'\n | 'x-ai/grok-4.1-fast'\n | 'x-ai/grok-code-fast-1'\n\n // Qwen\n | 'qwen/qwen3-max'\n | 'qwen/qwen3-coder-plus'\n | 'qwen/qwen3-coder'\n | 'qwen/qwen3-coder:nitro'\n | 'qwen/qwen3-coder-flash'\n | 'qwen/qwen3-235b-a22b-2507'\n | 'qwen/qwen3-235b-a22b-2507:nitro'\n | 'qwen/qwen3-235b-a22b-thinking-2507'\n | 'qwen/qwen3-235b-a22b-thinking-2507:nitro'\n | 'qwen/qwen3-30b-a3b'\n | 'qwen/qwen3-30b-a3b:nitro'\n\n // DeepSeek\n | 'deepseek/deepseek-v4-pro'\n | 'deepseek-v4-pro'\n | 'deepseek/deepseek-v4-flash'\n | 'deepseek-v4-flash'\n | 'deepseek/deepseek-chat-v3-0324'\n | 'deepseek/deepseek-chat-v3-0324:nitro'\n | 'deepseek/deepseek-r1-0528'\n | 'deepseek/deepseek-r1-0528:nitro'\n\n // Other open source models\n | 'moonshotai/kimi-k2'\n | 'moonshotai/kimi-k2:nitro'\n | 'moonshotai/kimi-k2.6'\n | 'z-ai/glm-5'\n | 'z-ai/glm-5.1'\n | 'z-ai/glm-4.6'\n | 'z-ai/glm-4.6:nitro'\n | 'z-ai/glm-4.7'\n | 'z-ai/glm-4.7:nitro'\n | 'z-ai/glm-4.7-flash'\n | 'z-ai/glm-4.7-flash:nitro'\n | 'minimax/minimax-m2.5'\n | 'minimax/minimax-m2.7'\n | (string & {})\n\nimport type { ToolName, GetToolParams } from './tools'\nimport type {\n Message,\n ToolResultOutput,\n JsonObjectSchema,\n MCPConfig,\n Logger,\n} from './util-types'\n\nexport type { ToolName, GetToolParams }\n" -export const toolsSource = "/**\n * Union type of all available tool names\n */\nexport type ToolName =\n | 'add_message'\n | 'ask_user'\n | 'check_background_agent'\n | 'check_job'\n | 'code_search'\n | 'end_turn'\n | 'edit_transaction'\n | 'edit_3d_asset'\n | 'find_files'\n | 'find_files_matching_content'\n | 'git_status'\n | 'git_branch'\n | 'get_task'\n | 'get_change_review_bundle'\n | 'inspect_workspace'\n | 'inspect_environment'\n | 'inspect_3d_asset'\n | 'get_affected_tests'\n | 'get_build_targets'\n | 'inspect_codebase_structure'\n | 'inspect_feature_completeness'\n | 'evaluate_audit_coverage'\n | 'glob'\n | 'kill_job'\n | 'list_directory'\n | 'list_jobs'\n | 'lookup_agent_info'\n | 'query_index'\n | 'read_docs'\n | 'read_files'\n | 'read_image'\n | 'render_3d_preview'\n | 'read_logs'\n | 'read_outline'\n | 'read_subtree'\n | 'replace_range'\n | 'rewrite_symbol'\n | 'render_ui'\n | 'run_file_change_hooks'\n | 'run_targeted_validation'\n | 'run_terminal_command'\n | 'set_messages'\n | 'set_output'\n | 'skill'\n | 'spawn_agents'\n | 'str_replace'\n | 'suggest_followups'\n | 'task_completed'\n | 'think_deeply'\n | 'update_plan_status'\n | 'web_search'\n | 'write_file'\n | 'write_audit_findings'\n | 'write_todos'\n\n/**\n * Map of tool names to their parameter types\n */\nexport interface ToolParamsMap {\n add_message: AddMessageParams\n ask_user: AskUserParams\n check_background_agent: CheckBackgroundAgentParams\n check_job: CheckJobParams\n code_search: CodeSearchParams\n end_turn: EndTurnParams\n edit_transaction: EditTransactionParams\n edit_3d_asset: Edit3dAssetParams\n find_files: FindFilesParams\n find_files_matching_content: FindFilesMatchingContentParams\n git_status: GitStatusParams\n git_branch: GitBranchParams\n get_task: GetTaskParams\n get_change_review_bundle: GetChangeReviewBundleParams\n inspect_workspace: InspectWorkspaceParams\n inspect_environment: InspectEnvironmentParams\n inspect_3d_asset: Inspect3dAssetParams\n get_affected_tests: GetAffectedTestsParams\n get_build_targets: GetBuildTargetsParams\n inspect_codebase_structure: InspectCodebaseStructureParams\n inspect_feature_completeness: InspectFeatureCompletenessParams\n evaluate_audit_coverage: EvaluateAuditCoverageParams\n glob: GlobParams\n kill_job: KillJobParams\n list_directory: ListDirectoryParams\n list_jobs: ListJobsParams\n lookup_agent_info: LookupAgentInfoParams\n query_index: QueryIndexParams\n read_docs: ReadDocsParams\n read_files: ReadFilesParams\n read_image: ReadImageParams\n render_3d_preview: Render3dPreviewParams\n read_logs: ReadLogsParams\n read_outline: ReadOutlineParams\n read_subtree: ReadSubtreeParams\n replace_range: ReplaceRangeParams\n rewrite_symbol: RewriteSymbolParams\n render_ui: RenderUiParams\n run_file_change_hooks: RunFileChangeHooksParams\n run_targeted_validation: RunTargetedValidationParams\n run_terminal_command: RunTerminalCommandParams\n set_messages: SetMessagesParams\n set_output: SetOutputParams\n skill: SkillParams\n spawn_agents: SpawnAgentsParams\n str_replace: StrReplaceParams\n suggest_followups: SuggestFollowupsParams\n task_completed: TaskCompletedParams\n think_deeply: ThinkDeeplyParams\n update_plan_status: UpdatePlanStatusParams\n web_search: WebSearchParams\n write_file: WriteFileParams\n write_audit_findings: WriteAuditFindingsParams\n write_todos: WriteTodosParams\n}\n\n/**\n * Add a new message to the conversation history. To be used for complex requests that can't be solved in a single step, as you may forget what happened!\n */\nexport interface AddMessageParams {\n role: 'user' | 'assistant'\n content: string\n}\n\n/**\n * Ask the user a list of multiple choice questions. Each question must have at least 2 options. The agent execution will pause until the user submits their answers.\n */\nexport interface AskUserParams {\n /** List of multiple choice questions to ask the user */\n questions: {\n /** The question to ask the user */\n question: string\n /** Optional short display label. Values longer than 18 Unicode code points are truncated instead of rejecting the question. */\n header?: string\n /** Array of answer options with label and optional description. */\n options: {\n /** The display text for this option */\n label: string\n /** Explanation shown when option is focused */\n description?: string\n }[]\n /** If true, allows selecting multiple options (checkbox). If false, single selection only (radio). */\n multiSelect?: boolean\n /** Validation rules for \"Other\" text input */\n validation?: {\n /** Maximum length for \"Other\" text input */\n maxLength?: number\n /** Minimum length for \"Other\" text input */\n minLength?: number\n /** Regex pattern for \"Other\" text input */\n pattern?: string\n /** Custom error message when pattern fails */\n patternError?: string\n }\n }[]\n}\n\n/**\n * Join/wait on a background agent turn started by spawn_agents({ background: true }): returns the sequenced agent_chunk events produced since the cursor plus the unified job state. Use it to observe a long-running background agent without blocking the turn.\n */\nexport interface CheckBackgroundAgentParams {\n /** The jobId returned by spawn_agents({ background: true }) for the background agent turn. */\n jobId: string\n /** Optional sequence cursor from a prior response. Polling is idempotent for an explicit cursor; nextCursor can be supplied on the next call. */\n cursor?: number\n /** Optional substring to wait for in the new streamed chunks before returning (follow mode). Returns early as soon as it appears in any chunk payload. Useful for waiting until a background agent emits a specific milestone (e.g. a tool_result or a text marker). */\n wait_for?: string\n /** Max seconds to wait for new chunks / the wait_for pattern. 0 (default) returns immediately with whatever new chunks exist (poll mode); >0 blocks up to this long (follow mode). */\n timeout_seconds?: number\n /** When true, explicitly cancel the running background agent before returning its final status. Defaults to false. */\n cancel?: boolean\n}\n\n/**\n * Join/wait on a background job started by run_terminal_command: returns the sequenced output events produced since the last check plus the unified job state and exit code. Use it to observe a long-running process without blocking the turn. To watch an arbitrary log file, start a `tail -f ` BACKGROUND job and check_job it with a wait_for pattern.\n */\nexport interface CheckJobParams {\n /** The jobId returned by run_terminal_command with process_type: BACKGROUND. */\n jobId: string\n /** Optional substring to wait for in the new output before returning (follow mode). Returns early as soon as it appears (e.g. \"Listening on\" / \"compiled successfully\"). */\n wait_for?: string\n /** Max seconds to wait for new output / the wait_for pattern. 0 (default) returns immediately with whatever new output exists (poll mode); >0 blocks up to this long (follow mode). */\n timeout_seconds?: number\n /** Follow mode only: SIGTERM the job on follow-timeout. Poll mode never kills. Default false. */\n kill_on_timeout?: boolean\n}\n\n/**\n * Search for string patterns in the project's files. This tool uses ripgrep (rg), a fast line-oriented search tool. Use this tool only when read_files is not sufficient to find the files you need.\n */\nexport interface CodeSearchParams {\n /** The pattern to search for. */\n pattern: string\n /** Optional safe ripgrep flags as one string or argv tokens (e.g., \"-i -g *.ts -A 2\" or [\"-i\", \"-g\", \"*.ts\", \"-A\", \"2\"]). Allowed: -i/--ignore-case, -S/--smart-case, -s/--case-sensitive, -w/--word-regexp, -F/--fixed-strings, -U/--multiline, --multiline-dotall, -g/--glob, -t/--type, -T/--type-not, plus context -A/-B/-C (and long forms). JSON quotes delimit the string; do not embed another quote pair around the entire expression. Line numbers are automatic; -n/--line-number are ignored. Output-shape flags such as -c/--count, --count-matches, -l, -v/--invert-match, -r/--replace, --exec, and -z/--null are rejected. */\n flags?: string | string[]\n /** Optional working directory or single file to search within, relative to the project root or absolute. Absolute paths may be outside the project. A directory becomes ripgrep's cwd and scopes the search under that path (plus existing blessed hidden dirs when no paths are given); a file scopes the search to that file only (process cwd = project root when the file is under the project, else the file's parent). Defaults to searching the entire project root. */\n cwd?: string\n /** Optional list of file and/or directory paths to search (relative to the project root, or absolute). When non-empty, ripgrep searches only these targets instead of the whole cwd tree (and does not auto-expand hidden dirs). Can be combined with a file cwd. */\n paths?: string[]\n /** Maximum number of results to return per file. Defaults to 15. There is also a global limit of 250 results across all files. */\n maxResults?: number\n}\n\n/**\n * End your turn, regardless of any new tool results that might be coming. This will allow the user to type another prompt.\n */\nexport interface EndTurnParams {}\n\n/**\n * Parameters for edit_transaction tool\n */\nexport interface EditTransactionParams {\n edits: (\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'str_replace'\n replacements: {\n oldString: string\n newString: string\n allowMultiple?: boolean\n occurrenceIndex?: number\n /** Optional authenticated cap.v3 readCapability copied verbatim from the matching fresh read_files editAnchor. */\n basedOnRead?: string\n /** For deletion replacements only (newString is empty): treat a missing oldString as an already-applied no-op. Use only for explicit idempotent cleanup retries, never for ordinary edits. When every requested change resolves to such a no-op - every replacement of a standalone str_replace call, or every edit of an edit_transaction - the call succeeds with zero file changes and the skip messages rather than failing. When combined with occurrenceIndex, a partially-applied cleanup also skips: fewer remaining exact occurrences than the requested index means that occurrence is treated as already applied. Only valid when newString is empty; both the input and provider schemas reject any other combination. */\n skipIfMissing?: boolean\n }[]\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n /** A structured edit dispatched by operation kind. */\n type: 'structured'\n /** Structured edit operation to apply to this file. */\n operation:\n | {\n /** Deterministic text insertion. */\n kind: 'insert_text'\n /** 1-indexed insertion position. */\n position: {\n /** 1-indexed target line. */\n line: number\n /** 1-indexed target column. */\n column: number\n }\n text: string\n }\n | {\n /** Language-aware import insertion. */\n kind: 'insert_import'\n /** Complete language-native import statement to add, e.g. \"import { foo } from 'bar'\", \"from app import value\", or \"use crate::value\". */\n importStatement: string\n }\n | {\n /** Language-aware import removal. */\n kind: 'remove_import'\n /** Complete language-native import statement to remove. Required unless moduleSpecifier is provided. */\n importStatement?: string\n /** Module specifier to remove imports from, e.g. \"react\" or \"./helper\". */\n moduleSpecifier?: string\n }\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'create'\n /** Exact bytes to write to the new file. */\n content: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'delete'\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'move'\n /** New project-relative path. The destination must be absent. */\n destinationPath: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'replace_range'\n readCapability: string\n startLine?: number\n endLine?: number\n occurrence?: {\n match: string\n occurrence?: number\n }\n newContent: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'rewrite_symbol'\n symbol: string\n content: string\n occurrence?: number\n /** Optional cap.v3 copied from the matching read_files symbol slice. It authorizes exactly the symbol and its contiguous preceding comment block. */\n readCapability?: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'patch'\n diff: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'write_file'\n content: string\n /** Optional whole-file-covering cap.v3 from a fresh complete whole-file read. Only a full-file capability with a hash matching current content may authorize overwrite; partial ranges never authorize write_file. */\n basedOnRead?: string\n }\n )[]\n}\n\n/**\n * Parameters for edit_3d_asset tool\n */\nexport interface Edit3dAssetParams {\n /** Project-relative .blend path. */\n path: string\n /** Exact source hash returned by inspect_3d_asset. */\n source_hash: string\n operations: (\n | {\n type: 'rename_object'\n object: string\n new_name: string\n }\n | {\n type: 'set_object_transform'\n object: string\n location?: any[]\n rotation_degrees?: any[]\n scale?: any[]\n }\n | {\n type: 'set_render_resolution'\n width: number\n height: number\n percentage?: number\n }\n | {\n type: 'set_frame_range'\n start: number\n end: number\n }\n )[]\n}\n\n/**\n * Find several files related to a brief natural language description of the files or the name of a function or class you are looking for.\n */\nexport interface FindFilesParams {\n /** A brief natural language description of the files or the name of a function or class you are looking for. It's also helpful to mention a directory or two to look within. */\n prompt: string\n}\n\n/**\n * List unique file paths whose content matches a pattern, with optional symbol grouping. Built on top of ripgrep (rg).\n */\nexport interface FindFilesMatchingContentParams {\n /** Regex pattern (ripgrep syntax) to match file content against. */\n pattern: string\n /** Optional safe ripgrep flags as one string or argv tokens. Allowed: -i/--ignore-case, -S/--smart-case, -s/--case-sensitive, -w/--word-regexp, -F/--fixed-strings, -U/--multiline, --multiline-dotall, -g/--glob, -t/--type, -T/--type-not. Examples: \"-g *.ts -g *.tsx\" or [\"-g\", \"*.ts\", \"-g\", \"*.tsx\"]. Do not quote the entire expression inside the JSON string. Output-shape flags such as -c/--count, --count-matches, -l, -v/--invert-match, context -A/-B/-C, -r/--replace, --exec, and -z/--null are rejected (this tool forces -l or --json itself). Redundant -n/--line-number inputs are ignored. */\n flags?: string | string[]\n /** Optional working directory or single file to search within, relative to the project root or absolute. Absolute paths may be outside the project. A directory becomes ripgrep's cwd and scopes the search under that path (plus existing blessed hidden dirs); a file scopes the search to that file only (process cwd = project root when the file is under the project, else the file's parent). Defaults to the project root. */\n cwd?: string\n /** Maximum number of unique files to return. Defaults to 100. */\n maxFiles?: number\n /** When true, also return the names of the top-level symbols (functions, classes, methods, exports, constants) that contain each match, plus the per-file match count. Symbol extraction is heuristic and works best for JS/TS/Python/Go/Rust source files; languages without a recognized declaration shape produce an empty symbols list. */\n groupBySymbol?: boolean\n /** Maximum seconds to let ripgrep run before returning partial results. Defaults to 15. */\n timeoutSeconds?: number\n}\n\n/**\n * Read-only git status and (optionally) diff for the current project.\n */\nexport interface GitStatusParams {\n /** When true, also return the unified diff of uncommitted changes. */\n include_diff?: boolean\n /** When true with include_diff, returns the staged diff instead of unstaged. */\n staged?: boolean\n /** Optional path to scope status/diff to (relative to project root). */\n path?: string\n /** Maximum characters of diff output to return. Defaults to 40,000. */\n max_chars?: number\n}\n\n/**\n * Create a new git branch, optionally switching to it. Refuses to branch when the working tree is dirty unless `allow_dirty` is true.\n */\nexport interface GitBranchParams {\n /** Name of the branch to create. Must start with an alphanumeric character and contain only [a-zA-Z0-9._/-]. */\n branch_name: string\n /** When true (default), create AND switch to the branch (`git checkout -b`). When false, only create the branch (`git branch`), leaving the current branch checked out. */\n switch?: boolean\n /** When true, skip the dirty-tree refusal check. Defaults to false — the tool refuses to branch when the working tree has uncommitted changes. */\n allow_dirty?: boolean\n}\n\n/**\n * Parameters for get_task tool\n */\nexport interface GetTaskParams {\n /** Optional plan session slug. Defaults to .agents/ACTIVE_SESSION. */\n session?: string\n}\n\n/**\n * Parameters for get_change_review_bundle tool\n */\nexport interface GetChangeReviewBundleParams {\n max_chars?: number\n}\n\n/**\n * Inspect the current repository/worktree identity and Git state without modifying it.\n */\nexport interface InspectWorkspaceParams {}\n\n/**\n * Parameters for inspect_environment tool\n */\nexport interface InspectEnvironmentParams {}\n\n/**\n * Parameters for inspect_3d_asset tool\n */\nexport interface Inspect3dAssetParams {\n /** Project-relative 3D asset path. */\n path: string\n}\n\n/**\n * Parameters for get_affected_tests tool\n */\nexport interface GetAffectedTestsParams {\n files: string[]\n}\n\n/**\n * Parameters for get_build_targets tool\n */\nexport interface GetBuildTargetsParams {\n files: string[]\n}\n\n/**\n * Parameters for inspect_codebase_structure tool\n */\nexport interface InspectCodebaseStructureParams {\n scope?: string[]\n}\n\n/**\n * Parameters for inspect_feature_completeness tool\n */\nexport interface InspectFeatureCompletenessParams {\n feature: string\n snapshot_id: string\n scope?: string[]\n}\n\n/**\n * Parameters for evaluate_audit_coverage tool\n */\nexport interface EvaluateAuditCoverageParams {\n snapshot_id: string\n structural_receipts: {\n schema_version: 1\n snapshot_id: string\n shard_id: string\n subsystem_ids: string[]\n files: string[]\n domains: (\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n )[]\n }[]\n features: {\n schema_version: 1\n snapshot_id: string\n feature: string\n evidence_kind: 'heuristic' | 'verified'\n evidence: {\n entrypoints: string[]\n implementation: string[]\n consumers: string[]\n tests: string[]\n docs: string[]\n failure_states: string[]\n }\n }[]\n out_of_scope?: {\n id: string\n reason: string\n }[]\n scope?: string[]\n}\n\n/**\n * Search for files matching a glob pattern. Returns matching file paths sorted by modification time (newest first, then path for deterministic ties).\n */\nexport interface GlobParams {\n /** Glob pattern to match files against (e.g., *.js, src/glob/*.ts, glob/test/glob/*.go). */\n pattern: string\n /** Optional working directory or file path, relative to project root. If a directory, the glob pattern is matched against paths relative to this cwd, while returned files remain project-relative. If a file path, the pattern is matched against that file only (full path or basename). If not provided, searches from project root. */\n cwd?: string\n}\n\n/**\n * Cancel a background job started by run_terminal_command.\n */\nexport interface KillJobParams {\n /** The jobId returned by run_terminal_command with process_type: BACKGROUND. */\n jobId: string\n /** Signal to send. Defaults to SIGTERM; use SIGKILL only if graceful termination fails. */\n signal?: 'SIGTERM' | 'SIGKILL'\n}\n\n/**\n * List files and directories in the specified path. Returns separate arrays of file names and directory names.\n */\nexport interface ListDirectoryParams {\n /** Directory path to list, relative to the project root. */\n path: string\n}\n\n/**\n * List this run's background jobs (shell processes and background agents, running and settled) with statuses, bucketed pending process/log output relative to the last check_job consumer cursor (agents usually show pending: 'none'), and a gap flag.\n */\nexport interface ListJobsParams {}\n\n/**\n * Retrieve information about an agent by ID\n */\nexport interface LookupAgentInfoParams {\n /** Agent ID (short local or full published format) */\n agentId: string\n}\n\n/**\n * Query the local codebase graph index to find relevant files ranked by symbol names, imports, headings, paths, doc concepts, and graph relationships. The index is built automatically on startup.\n */\nexport interface QueryIndexParams {\n /** Natural language query or keyword terms describing the files you are looking for. Optional for graph modes when from/to paths are provided. For example: \"authentication\", \"database migrations\", \"editor mutation logic\", \"React components\". */\n query?: string\n /** Maximum number of results to return. Defaults to 20. */\n limit?: number\n /** Optional list of file extensions to filter results (without dot). E.g. [\"ts\", \"tsx\"] for TypeScript only. */\n fileTypes?: string[]\n /** Optional normalized project-relative directory prefixes. Results outside every prefix are excluded before ranking/limiting. */\n pathPrefixes?: string[]\n /** search|explain|neighbors|path|commands|references — see tool description. */\n mode?: 'search' | 'neighbors' | 'path' | 'explain' | 'commands' | 'references'\n /** Optional source file path for neighbors, path, and references modes. */\n from?: string\n /** Optional target file path for path mode. Also used as the seed file for references mode when from is omitted or not indexed. */\n to?: string\n}\n\n/**\n * Fetch up-to-date documentation for libraries and frameworks using Context7 API.\n */\nexport interface ReadDocsParams {\n /** The library or framework name (e.g., \"Next.js\", \"MongoDB\", \"React\"). Use the official name as it appears in documentation if possible. Only public libraries available in Context7's database are supported, so small or private libraries may not be available. */\n libraryTitle: string\n /** Specific topic to focus on (e.g., \"routing\", \"hooks\", \"authentication\") */\n topic: string\n /** Optional maximum number of tokens to return. Defaults to 10000. Values less than 10000 are automatically increased to 10000. */\n max_tokens?: number\n}\n\n/**\n * Read multiple files from disk and return their contents. Use this tool to read as many files as would be helpful to answer the user's request.\n */\nexport interface ReadFilesParams {\n /** Whole-file paths to read. Complete results include editAnchor.readCapability for follow-up edits. */\n paths?: string[]\n /** 1-indexed inclusive line ranges. Sole `paths` entry infers missing path. */\n ranges?: {\n /** Project-relative file path. */\n path: string\n /** 1-indexed inclusive start line. Defaults to 1. */\n startLine?: number\n /** 1-indexed inclusive end line. Defaults to the last line. */\n endLine?: number\n }[]\n /** Contiguous line windows; each complete window mints a scoped cap.v3 editAnchor. */\n windows?: {\n /** File path to read in contiguous line windows, relative to the project root. */\n path: string\n /** Lines per window. Defaults to 400, capped at 5000. */\n windowSize?: number\n /** 1-indexed window number to return. Omit to get the window manifest (totalLines, windowSize, windowCount) plus the first window. */\n window?: number\n }[]\n /** Literal-anchored context blocks with a scoped cap.v3 editAnchor per block. */\n around?: {\n /** File path to read a content-anchored block from, relative to the project root. */\n path: string\n /** Exact literal string to anchor on. Robust to line-number drift. */\n match: string\n /** 1-indexed occurrence of `match` to anchor on. Defaults to 1. */\n occurrence?: number\n /** Lines of context to include on each side of the match, clamped at file boundaries. Defaults to 40, capped at 2000. */\n contextLines?: number\n }[]\n /** Nth top-level symbol by name (rewrite_symbol occurrence semantics); prefer batch `symbols` when possible. */\n symbol?: {\n /** File path to extract a symbol slice from, relative to the project root. */\n path: string\n /** Top-level symbol name (function, class, interface, method) to pull, as shown by read_outline. */\n name: string\n /** When multiple top-level symbols share this name, the 1-indexed one to return. Defaults to 1. Matches rewrite_symbol occurrence semantics. */\n occurrence?: number\n }[]\n /** Named symbol slices with editAnchors; prefer over full reads when names are known. */\n symbols?: {\n /** Project-relative file path. */\n path: string\n /** Symbol names to slice. */\n names: string[]\n }[]\n}\n\n/**\n * Read image files from disk and return them as model-visible image media.\n */\nexport interface ReadImageParams {\n /** List of image file paths to read. */\n paths: string[]\n}\n\n/**\n * Parameters for render_3d_preview tool\n */\nexport interface Render3dPreviewParams {\n /** Project-relative 3D asset path. */\n path: string\n views?: ('camera' | 'perspective' | 'front' | 'side' | 'top')[]\n mode?: 'material' | 'clay' | 'wireframe'\n width?: number\n height?: number\n}\n\n/**\n * Read the last N lines from a log/text file or background job log without starting a background tail process.\n */\nexport interface ReadLogsParams {\n /** Path to the log file, relative to the project root unless absolute. Required unless jobId is provided. */\n path?: string\n /** Background job id returned by run_terminal_command(process_type: BACKGROUND). When provided, reads the job log file directly. */\n jobId?: string\n /** Number of trailing lines to read. Defaults to 200. */\n lines?: number\n /** Maximum characters to return. Defaults to 20,000. */\n max_chars?: number\n}\n\n/**\n * Generate an outline of imports, exports, classes, methods, and function signatures in a source file without reading the entire implementation.\n */\nexport interface ReadOutlineParams {\n /** File path to generate the AST-like outline for, relative to the project root. */\n path: string\n}\n\n/**\n * Read one or more directory subtrees (as a blob including subdirectories, file names, and parsed variables within each source file) or return parsed variable names for files. If no paths are provided, returns the entire project tree.\n */\nexport interface ReadSubtreeParams {\n /** List of paths to directories or files. Relative to the project root. If omitted, the entire project tree is used. */\n paths?: string[]\n /** Maximum token budget for the subtree blob; the tree will be truncated to fit within this budget by first dropping file variables and then removing the most-nested files and directories. */\n maxTokens?: number\n}\n\n/**\n * Replace all of, a contained sub-range of, or the Nth literal occurrence inside content observed through one fresh cap.v3 read capability.\n */\nexport interface ReplaceRangeParams {\n /** The path to the file to edit. */\n path: string\n /** Copy the cap.v3 readCapability verbatim from the matching fresh read_files editAnchor. The token supplies the observed line bounds and content hash. */\n readCapability: string\n /** Optional 1-indexed target start within the capability-covered range. Omit with endLine to replace the complete observed range. */\n startLine?: number\n /** Optional 1-indexed target end within the capability-covered range. Omit with startLine to replace the complete observed range. */\n endLine?: number\n /** Optional occurrence targeting: replace the 1-indexed occurrence (default 1) of the exact literal match found inside the capability-authorized range. Mutually exclusive with startLine/endLine. */\n occurrence?: {\n match: string\n occurrence?: number\n }\n /** Complete replacement content for the selected line range. */\n newContent: string\n}\n\n/**\n * Replace a whole symbol's definition by name using the file's syntax tree, without copying its current text. Resolves the exact AST range and applies it through the safe str_replace path (atomic, anchored).\n */\nexport interface RewriteSymbolParams {\n /** File path containing the symbol, relative to the project root. */\n path: string\n /** Name of the function/class/method/type/interface to replace (as shown by read_outline). */\n symbol: string\n /** The complete new source for the symbol, replacing its entire current definition (e.g. the whole function including its signature and body). Provide REAL newlines/tabs in the string — literal backslash-n (\\n) and backslash-t (\\t) sequences are not interpreted and will be written verbatim into the file. This matches str_replace. */\n content: string\n /** When multiple top-level symbols share this name, the 1-indexed one to replace. */\n occurrence?: number\n /** Optional cap.v3 copied from the matching read_files symbol slice. Under strict read-before-edit this authorizes exactly the symbol and its contiguous preceding comment block. */\n readCapability?: string\n}\n\n/**\n * Render a small interactive UI widget in the Openbuff CLI. Currently supports a button that opens a link.\n */\nexport interface RenderUiParams {\n /** The UI widget to render. */\n widget: {\n /** Widget type. Currently, the only supported widget is button. */\n type: 'button'\n /** Short button label shown to the user. */\n text: string\n /** The http:// or https:// URL to open when the user clicks the button. */\n link: string\n /** Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions. */\n variant?: 'primary' | 'secondary'\n }\n}\n\n/**\n * Parameters for run_file_change_hooks tool\n */\nexport interface RunFileChangeHooksParams {\n /** List of file paths that were changed and should trigger file change hooks */\n files: string[]\n}\n\n/**\n * Parameters for run_targeted_validation tool\n */\nexport interface RunTargetedValidationParams {\n snapshot_id: string\n files: string[]\n artifact_kinds?: string[]\n}\n\n/**\n * Execute a CLI command from the **project root** (different from the user's cwd).\n */\nexport interface RunTerminalCommandParams {\n /** CLI command valid for user's OS. */\n command: string\n /** SYNC (default) for finite commands that exit: waits and returns output. BACKGROUND only for long-running or never-exiting processes (dev servers, watchers, log tails): starts a detached job and returns a jobId immediately so the turn is not blocked. Live job_update already drives the user UI; use check_job for agent-side readiness/exitCode/join, not solely for user progress. */\n process_type?: 'SYNC' | 'BACKGROUND'\n /** For BACKGROUND commands only: keep the job running if the owning request is cancelled. Defaults to false. */\n detach?: boolean\n /** The working directory to run the command in. Default is the project root. */\n cwd?: string\n /** Set to -1 for no timeout. Does not apply for BACKGROUND commands. Default 30 */\n timeout_seconds?: number\n /** Runtime-managed background job owner; agents must omit. */\n owner?: {\n clientSessionId: string\n rootRunId: string\n parentRunId: string\n parentAgentId: string\n }\n}\n\n/**\n * Atomically replace conversation history and, when supplied, commit a validated structured task-memory revision.\n */\nexport interface SetMessagesParams {\n messages: any\n taskMemory?: {\n schemaVersion: 1\n goal?: string\n requirements?: string[]\n decisions?: string[]\n filesInspected?: string[]\n editsMade?: string[]\n validationResults?: string[]\n reviewReceipts?: string[]\n blockers?: string[]\n nextActions?: string[]\n historicalSummary?: string\n evidence?: {\n id: string\n kind:\n | 'requirement'\n | 'decision'\n | 'read'\n | 'edit'\n | 'validation'\n | 'review'\n | 'blocker'\n | 'handoff'\n | 'note'\n summary: string\n source?: string\n path?: string\n freshnessHash?: string\n workspaceRevision?: number\n verifiedAt?: number\n supersedes?: string[]\n stale?: boolean\n }[]\n workspaceRevision?: number\n workspaceSnapshotId?: string\n }\n expectedTaskMemoryRevision?: number\n}\n\n/**\n * JSON object to set as the agent output. The shape of the parameters are specified dynamically further down in the conversation. This completely replaces any previous output. If the agent was spawned, this value will be passed back to its parent. If the agent has an outputSchema defined, the output will be validated against it.\n */\nexport interface SetOutputParams {\n data?: Record\n [key: string]: any\n}\n\n/**\n * Load a skill by name to get its full instructions. Skills provide reusable behaviors and instructions.\n */\nexport interface SkillParams {\n /** The name of the skill to load */\n name: string\n}\n\n/**\n * Spawn up to 12 agents and send a prompt and/or parameters to each of them. These agents will run in parallel. Note that that means they will run independently. Split larger work into bounded waves. If you need to run agents sequentially, use spawn_agents with one agent at a time instead.\n */\nexport interface SpawnAgentsParams {\n agents: {\n /** Agent to spawn. Must be a name from the live \"You can spawn the following agents\" catalog (hyphenated ids; underscores accepted). */\n agent_type: string\n /** Prompt to send to the agent */\n prompt?: string\n /** If true, return jobId immediately and run as in-process coroutine; poll with check_background_agent. Defaults to false (blocking). Cannot outlive this CLI session. */\n background?: boolean\n /** Optional structured handoff; additive — non-consumers still get prompt/params. */\n handoff?:\n | {\n schemaVersion: 1\n taskId: string\n role:\n | 'orchestrator'\n | 'explorer'\n | 'thinker'\n | 'editor'\n | 'repair-editor'\n | 'test-writer'\n | 'doc-writer'\n | 'dependency-manager'\n | 'debugger'\n | 'validator'\n | 'reviewer'\n | 'security-reviewer'\n | 'committer'\n | 'synthesizer'\n | 'specialist'\n | 'general'\n objective: string\n requirements: {\n id: string\n text: string\n required: boolean\n }[]\n acceptanceCriteria: {\n id: string\n behavior: string\n verification: string\n }[]\n context:\n | {\n path: string\n symbols: string[]\n reason: string\n confidence: 'confirmed' | 'inferred' | 'unknown'\n freshnessHash?: string\n workspaceRevision?: number\n }[]\n | Record\n | string\n currentBehavior?: string\n desiredBehavior?: string\n invariants?: string[]\n nonGoals: string[]\n risks?: string[]\n unknowns?: string[]\n findings: {\n id: string\n text: string\n files: string[]\n snapshotFingerprint: string\n }[]\n permissions: {\n readablePaths: string[]\n writablePaths: string[]\n allowedTools: string[]\n }\n workspaceRevision?: number\n workspaceSnapshotId?: string\n summary?: string\n artifacts?: string[]\n successCriteria?: string[]\n constraints?: string[]\n }\n | Record\n /** Optional wall-clock deadline seconds; omit or -1 for none. Agent defaultTimeoutMs still applies when set. */\n timeout_seconds?: number\n /** Parameters object for the agent */\n params?: {\n /** Terminal command to run (basher, tmux-cli) */\n command?: string\n /** What information from the command output is desired (basher) */\n what_to_summarize?: string\n /** Timeout for command. Set to -1 for no timeout. Default 30 (basher) */\n timeout_seconds?: number\n /** Save full command output to a /tmp log and extract failure lines for long SYNC command output (basher) */\n save_full_log?: boolean\n /** grep -E failure extraction pattern used with save_full_log (basher) */\n failure_pattern?: string\n /** Maximum extracted failure lines to return with save_full_log (basher) */\n max_failure_lines?: number\n /** Array of code search queries (code-searcher) */\n searchQueries?: {\n /** The pattern to search for */\n pattern: string\n /** Optional ripgrep flags as one string or argv tokens (e.g. \"-i -g *.ts\" or [\"-i\", \"-g\", \"*.ts\"]). Do not quote the entire expression inside the JSON string. */\n flags?: string | string[]\n /** Optional working directory relative to project root */\n cwd?: string\n /** Max results per file. Default 15 */\n maxResults?: number\n }[]\n /** Relevant file paths to read (general-agent) */\n filePaths?: string[]\n /** Relevant directory paths to inventory (general-agent) */\n directoryPaths?: string[]\n /** Directories to search within (file-picker) */\n directories?: string[]\n /** Starting URL to navigate to (browser-use) */\n url?: string\n /** Exact task-owned paths eligible for staging (git-committer) */\n owned_paths?: string[]\n /** Optional branch to create or switch to (git-committer) */\n branch_name?: string\n /** Create and switch to branch_name when true (git-committer) */\n branch_switch?: boolean\n /** Allow branch create/switch on a dirty worktree (git-committer) */\n allow_dirty_branch?: boolean\n /** Push the resulting feature branch when authorized (git-committer) */\n push?: boolean\n /** Remote used for fetch/push (git-committer) */\n remote?: string\n /** Assigned gate snapshot fingerprint (reviewer specialists) */\n snapshot_id?: string\n /** Changed file paths to review (security-reviewer) */\n changed_files?: string[]\n /** Opaque snapshot token to echo (security-reviewer) */\n snapshot_fingerprint?: string\n /** Package manager selected from repository manifests (dependency-manager) */\n manager?: string\n /** Dependency operation: add, remove, sync, restore, or update (dependency-manager) */\n operation?: string\n /** Exact package specifications (dependency-manager) */\n packages?: string[]\n /** Optional workspace selector (dependency-manager) */\n workspace?: string\n /** GitHub repository URL to clone (librarian) */\n repoUrl?: string\n /** Retain the owned /tmp clone after completion (librarian) */\n retainClone?: boolean\n /** Optional search or path patterns */\n patterns?: string[]\n /** Exact files in scope (reviewer specialists) */\n files?: string[]\n /** Optional agent-specific prompts */\n prompts?: string[]\n [key: string]: any\n }\n }[]\n}\n\n/**\n * Parameters for str_replace tool\n */\nexport interface StrReplaceParams {\n /** The file to edit. */\n path: string\n atomic?: boolean\n replacements: {\n oldString: string\n newString: string\n allowMultiple?: boolean\n occurrenceIndex?: number\n /** Optional authenticated cap.v3 readCapability copied verbatim from the matching fresh read_files editAnchor. */\n basedOnRead?: string\n /** For deletion replacements only (newString is empty): treat a missing oldString as an already-applied no-op. Use only for explicit idempotent cleanup retries, never for ordinary edits. When every requested change resolves to such a no-op - every replacement of a standalone str_replace call, or every edit of an edit_transaction - the call succeeds with zero file changes and the skip messages rather than failing. When combined with occurrenceIndex, a partially-applied cleanup also skips: fewer remaining exact occurrences than the requested index means that occurrence is treated as already applied. Only valid when newString is empty; both the input and provider schemas reject any other combination. */\n skipIfMissing?: boolean\n }[]\n}\n\n/**\n * Suggest clickable followup prompts to the user. Each followup becomes a card the user can click to send that prompt.\n */\nexport interface SuggestFollowupsParams {\n /** List of suggested followup prompts the user can click to send */\n followups: {\n /** The full prompt text to send as a user message when clicked */\n prompt: string\n /** Short display label for the card (defaults to truncated prompt if not provided) */\n label?: string\n }[]\n}\n\n/**\n * Signal that the task is complete. Use this tool when:\n- The user's request is completely fulfilled\n- You need clarification from the user before continuing\n- You are stuck or need help from the user to continue\n\nThis tool explicitly marks the end of your work on the current task.\n */\nexport interface TaskCompletedParams {}\n\n/**\n * Deeply consider complex tasks by brainstorming approaches and tradeoffs step-by-step.\n */\nexport interface ThinkDeeplyParams {\n /** Detailed step-by-step analysis. Initially keep each step concise (max ~5-7 words per step). */\n thought: string\n}\n\n/**\n * Parameters for update_plan_status tool\n */\nexport interface UpdatePlanStatusParams {\n /** Artifact path. Must be `.agents/sessions//PLAN.md`, `.agents/sessions//STATUS.md`, or `.agents/sessions//LESSONS.md`. Absolute paths and `..` traversal are rejected. Editing PLAN.md is permitted only for tri-state task toggles (not full overwrites). */\n path: string\n /** Targeted updates applied in order. Each entry rewrites at most one matching checklist line; unmatched updates fall through to `append`. */\n updates?: {\n /** Stable task ID at the start of a checklist line (for example `P2-T3`). Preferred over substring matching. */\n taskId?: string\n /** Substring of the existing task/checklist line to match (case-insensitive). The first matching `- [ ]`/`-[x]`/`-[~]`/`-[/]`/`-[!]` line in the artifact will be updated in place. */\n task?: string\n /** When provided, sets the checkbox state of the matched line (true -> `[x]`, false -> `[ ]`). Ignored when `status` is also provided. */\n completed?: boolean\n /** Explicit tri-state task status. When provided, overrides `completed`. Transitions a task to `in_progress` (`[~]`), `done` (`[x]`), `cancelled` (`[/]`), `blocked` (`[!]`), or back to `pending` (`[ ]`). */\n status?: 'pending' | 'in_progress' | 'done' | 'cancelled' | 'blocked'\n /** Optional short note to append to the matched line in parentheses. Preserves any existing trailing text on the line. */\n note?: string\n }[]\n /** Optional delimited entry appended at the end of the artifact (used when there is no matching task line for the change being recorded). */\n append?: {\n /** Short heading for an appended entry. Used to form a clearly delimited block (`## `). */\n heading: string\n /** Markdown body for the appended entry. Written verbatim under the heading. */\n body: string\n }\n /** Optional session-level status transition. When provided, `.agents/sessions//STATE.json` is created or updated to reflect the new lifecycle status. */\n sessionStatus?:\n | 'draft'\n | 'ready'\n | 'active'\n | 'executing'\n | 'validating'\n | 'reviewing'\n | 'blocked'\n | 'paused'\n | 'completed'\n | 'archived'\n /** Optional current-task pointer written as a `` annotation in PLAN.md. Pass an empty string or omit to clear the pointer. Only takes effect when path targets PLAN.md. */\n currentTask?: string\n /** Optional STATE.json compare-and-swap revision. The update fails without writing when the current revision differs. */\n expectedRevision?: number\n /** Validation or review evidence associated with a stable task ID. Completing a PLAN task requires a passed validation checkpoint with receiptIds. */\n checkpoint?: {\n taskId: string\n phase: 'validation' | 'review'\n passed: boolean\n summary?: string\n receiptIds?: string[]\n }\n}\n\n/**\n * Search the web for current information, or fetch the content of a specific URL.\n */\nexport interface WebSearchParams {\n /** The search query to find relevant web content. Required unless url is provided. */\n query?: string\n /** A specific URL to fetch and read the full text content of. When provided, fetches this page directly instead of searching. Useful for reading documentation, GitHub READMEs, blog posts, or any public web page. */\n url?: string\n /** Search depth - 'standard' for quick results, 'deep' for more comprehensive search. Default is 'standard'. Ignored when url is provided. */\n depth?: 'standard' | 'deep'\n /** When fetching a URL, also extract and return links found on the page. Enables navigation by letting you see what pages are linked. Default: true. */\n include_links?: boolean\n /** Maximum number of links to extract when include_links is true. Default: 40. */\n max_links?: number\n}\n\n/**\n * Create or overwrite a file with the given content.\n */\nexport interface WriteFileParams {\n /** Path to the file relative to the **project root** */\n path: string\n /** What the change is intended to do in only one sentence. */\n instructions: string\n /** Complete file content to write to the file. */\n content: string\n /** Optional whole-file-covering cap.v3 from a fresh complete whole-file read (paths or full-file range). Only a capability that covers the entire current file (startLine=1 through the current line count) with a hash matching current content may authorize overwrite; partial range capabilities never authorize write_file. */\n basedOnRead?: string\n}\n\n/**\n * Parameters for write_audit_findings tool\n */\nexport interface WriteAuditFindingsParams {\n /** Existing durable audit session slug under .agents/sessions/. */\n sessionSlug: string\n /** Unique shard identifier used as the findings filename. */\n shardId: string\n /** Exact snapshotId returned by inspect_codebase_structure. Required for a directly composable structuralReceipt; omitted only for legacy callers. */\n snapshotId?: string\n findings: {\n severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'\n domain:\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n | 'api-abi'\n path: string\n line?: number\n title: string\n risk: string\n fix: string\n evidence: string\n }[]\n coverage: {\n subsystemIds: string[]\n featureIds: string[]\n files: string[]\n domains?: (\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n )[]\n }\n noIssuesFound?: boolean\n}\n\n/**\n * Write a todo list to track tasks for multi-step implementations. Use this frequently to maintain an updated step-by-step plan.\n */\nexport interface WriteTodosParams {\n /** List of todos with their completion status. Add ALL of the applicable tasks to the list, so you don't forget to do anything. Try to order the todos the same way you will complete them. Do not mark todos as completed if you have not completed them yet! */\n todos: {\n /** Description of the task */\n task: string\n /** Whether the task is completed */\n completed: boolean\n }[]\n}\n\n/**\n * Get parameters type for a specific tool\n */\nexport type GetToolParams = ToolParamsMap[T]\n" +export const toolsSource = "/**\n * Union type of all available tool names\n */\nexport type ToolName =\n | 'add_message'\n | 'ask_user'\n | 'check_background_agent'\n | 'check_job'\n | 'code_search'\n | 'end_turn'\n | 'edit_transaction'\n | 'edit_3d_asset'\n | 'find_files'\n | 'find_files_matching_content'\n | 'git_status'\n | 'git_branch'\n | 'get_task'\n | 'get_change_review_bundle'\n | 'inspect_workspace'\n | 'inspect_environment'\n | 'inspect_3d_asset'\n | 'get_affected_tests'\n | 'get_build_targets'\n | 'inspect_codebase_structure'\n | 'inspect_feature_completeness'\n | 'evaluate_audit_coverage'\n | 'glob'\n | 'kill_job'\n | 'list_directory'\n | 'list_jobs'\n | 'lookup_agent_info'\n | 'query_index'\n | 'read_docs'\n | 'read_files'\n | 'read_image'\n | 'render_3d_preview'\n | 'read_logs'\n | 'read_outline'\n | 'read_subtree'\n | 'replace_range'\n | 'rewrite_symbol'\n | 'render_ui'\n | 'run_file_change_hooks'\n | 'run_targeted_validation'\n | 'run_terminal_command'\n | 'set_messages'\n | 'set_output'\n | 'skill'\n | 'spawn_agents'\n | 'str_replace'\n | 'suggest_followups'\n | 'task_completed'\n | 'think_deeply'\n | 'update_plan_status'\n | 'web_search'\n | 'write_file'\n | 'write_audit_findings'\n | 'write_todos'\n\n/**\n * Map of tool names to their parameter types\n */\nexport interface ToolParamsMap {\n add_message: AddMessageParams\n ask_user: AskUserParams\n check_background_agent: CheckBackgroundAgentParams\n check_job: CheckJobParams\n code_search: CodeSearchParams\n end_turn: EndTurnParams\n edit_transaction: EditTransactionParams\n edit_3d_asset: Edit3dAssetParams\n find_files: FindFilesParams\n find_files_matching_content: FindFilesMatchingContentParams\n git_status: GitStatusParams\n git_branch: GitBranchParams\n get_task: GetTaskParams\n get_change_review_bundle: GetChangeReviewBundleParams\n inspect_workspace: InspectWorkspaceParams\n inspect_environment: InspectEnvironmentParams\n inspect_3d_asset: Inspect3dAssetParams\n get_affected_tests: GetAffectedTestsParams\n get_build_targets: GetBuildTargetsParams\n inspect_codebase_structure: InspectCodebaseStructureParams\n inspect_feature_completeness: InspectFeatureCompletenessParams\n evaluate_audit_coverage: EvaluateAuditCoverageParams\n glob: GlobParams\n kill_job: KillJobParams\n list_directory: ListDirectoryParams\n list_jobs: ListJobsParams\n lookup_agent_info: LookupAgentInfoParams\n query_index: QueryIndexParams\n read_docs: ReadDocsParams\n read_files: ReadFilesParams\n read_image: ReadImageParams\n render_3d_preview: Render3dPreviewParams\n read_logs: ReadLogsParams\n read_outline: ReadOutlineParams\n read_subtree: ReadSubtreeParams\n replace_range: ReplaceRangeParams\n rewrite_symbol: RewriteSymbolParams\n render_ui: RenderUiParams\n run_file_change_hooks: RunFileChangeHooksParams\n run_targeted_validation: RunTargetedValidationParams\n run_terminal_command: RunTerminalCommandParams\n set_messages: SetMessagesParams\n set_output: SetOutputParams\n skill: SkillParams\n spawn_agents: SpawnAgentsParams\n str_replace: StrReplaceParams\n suggest_followups: SuggestFollowupsParams\n task_completed: TaskCompletedParams\n think_deeply: ThinkDeeplyParams\n update_plan_status: UpdatePlanStatusParams\n web_search: WebSearchParams\n write_file: WriteFileParams\n write_audit_findings: WriteAuditFindingsParams\n write_todos: WriteTodosParams\n}\n\n/**\n * Add a new message to the conversation history. To be used for complex requests that can't be solved in a single step, as you may forget what happened!\n */\nexport interface AddMessageParams {\n role: 'user' | 'assistant'\n content: string\n}\n\n/**\n * Ask the user a list of multiple choice questions. Each question must have at least 2 options. The agent execution will pause until the user submits their answers.\n */\nexport interface AskUserParams {\n /** List of multiple choice questions to ask the user */\n questions: {\n /** The question to ask the user */\n question: string\n /** Optional short display label. Values longer than 18 Unicode code points are truncated instead of rejecting the question. */\n header?: string\n /** Array of answer options with label and optional description. */\n options: {\n /** The display text for this option */\n label: string\n /** Explanation shown when option is focused */\n description?: string\n }[]\n /** If true, allows selecting multiple options (checkbox). If false, single selection only (radio). */\n multiSelect?: boolean\n /** Validation rules for \"Other\" text input */\n validation?: {\n /** Maximum length for \"Other\" text input */\n maxLength?: number\n /** Minimum length for \"Other\" text input */\n minLength?: number\n /** Regex pattern for \"Other\" text input */\n pattern?: string\n /** Custom error message when pattern fails */\n patternError?: string\n }\n }[]\n}\n\n/**\n * Join/wait on a background agent turn started by spawn_agents({ background: true }): returns the sequenced agent_chunk events produced since the cursor plus the unified job state. Use it to observe a long-running background agent without blocking the turn.\n */\nexport interface CheckBackgroundAgentParams {\n /** The jobId returned by spawn_agents({ background: true }) for the background agent turn. */\n jobId: string\n /** Optional sequence cursor from a prior response. Polling is idempotent for an explicit cursor; nextCursor can be supplied on the next call. */\n cursor?: number\n /** Optional substring to wait for in the new streamed chunks before returning (follow mode). Returns early as soon as it appears in any chunk payload. Useful for waiting until a background agent emits a specific milestone (e.g. a tool_result or a text marker). */\n wait_for?: string\n /** Max seconds to wait for new chunks / the wait_for pattern. 0 (default) returns immediately with whatever new chunks exist (poll mode); >0 blocks up to this long (follow mode). */\n timeout_seconds?: number\n /** When true, explicitly cancel the running background agent before returning its final status. Defaults to false. */\n cancel?: boolean\n}\n\n/**\n * Join/wait on a background job started by run_terminal_command: returns the sequenced output events produced since the last check plus the unified job state and exit code. Use it to observe a long-running process without blocking the turn. To watch an arbitrary log file, start a `tail -f ` BACKGROUND job and check_job it with a wait_for pattern.\n */\nexport interface CheckJobParams {\n /** The jobId returned by run_terminal_command with process_type: BACKGROUND. */\n jobId: string\n /** Optional substring to wait for in the new output before returning (follow mode). Returns early as soon as it appears (e.g. \"Listening on\" / \"compiled successfully\"). */\n wait_for?: string\n /** Max seconds to wait for new output / the wait_for pattern. 0 (default) returns immediately with whatever new output exists (poll mode); >0 blocks up to this long (follow mode). */\n timeout_seconds?: number\n /** Follow mode only: SIGTERM the job on follow-timeout. Poll mode never kills. Default false. */\n kill_on_timeout?: boolean\n}\n\n/**\n * Search for string patterns in the project's files. This tool uses ripgrep (rg), a fast line-oriented search tool. Use this tool only when read_files is not sufficient to find the files you need.\n */\nexport interface CodeSearchParams {\n /** The pattern to search for. */\n pattern: string\n /** Optional safe ripgrep flags as one string or argv tokens (e.g., \"-i -g *.ts -A 2\" or [\"-i\", \"-g\", \"*.ts\", \"-A\", \"2\"]). Allowed: -i/--ignore-case, -S/--smart-case, -s/--case-sensitive, -w/--word-regexp, -F/--fixed-strings, -U/--multiline, --multiline-dotall, -g/--glob, -t/--type, -T/--type-not, plus context -A/-B/-C (and long forms). JSON quotes delimit the string; do not embed another quote pair around the entire expression. Line numbers are automatic; -n/--line-number are ignored. Output-shape flags such as -c/--count, --count-matches, -l, -v/--invert-match, -r/--replace, --exec, and -z/--null are rejected. */\n flags?: string | string[]\n /** Optional working directory or single file to search within, relative to the project root or absolute. Absolute paths may be outside the project. A directory becomes ripgrep's cwd and scopes the search under that path (plus existing blessed hidden dirs when no paths are given); a file scopes the search to that file only (process cwd = project root when the file is under the project, else the file's parent). Defaults to searching the entire project root. */\n cwd?: string\n /** Optional list of file and/or directory paths to search (relative to the project root, or absolute). When non-empty, ripgrep searches only these targets instead of the whole cwd tree (and does not auto-expand hidden dirs). Can be combined with a file cwd. */\n paths?: string[]\n /** Maximum number of results to return per file. Defaults to 15. There is also a global limit of 250 results across all files. */\n maxResults?: number\n}\n\n/**\n * End your turn, regardless of any new tool results that might be coming. This will allow the user to type another prompt.\n */\nexport interface EndTurnParams {}\n\n/**\n * Parameters for edit_transaction tool\n */\nexport interface EditTransactionParams {\n edits: (\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'str_replace'\n replacements: {\n oldString: string\n newString: string\n allowMultiple?: boolean\n occurrenceIndex?: number\n /** Optional authenticated cap.v3 readCapability copied verbatim from the matching fresh read_files editAnchor. */\n basedOnRead?: string\n /** For deletion replacements only (newString is empty): treat a missing oldString as an already-applied no-op. Use only for explicit idempotent cleanup retries, never for ordinary edits. When every requested change resolves to such a no-op - every replacement of a standalone str_replace call, or every edit of an edit_transaction - the call succeeds with zero file changes and the skip messages rather than failing. When combined with occurrenceIndex, a partially-applied cleanup also skips: fewer remaining exact occurrences than the requested index means that occurrence is treated as already applied. Only valid when newString is empty; both the input and provider schemas reject any other combination. */\n skipIfMissing?: boolean\n }[]\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n /** A structured edit dispatched by operation kind. */\n type: 'structured'\n /** Structured edit operation to apply to this file. */\n operation:\n | {\n /** Deterministic text insertion. */\n kind: 'insert_text'\n /** 1-indexed insertion position. */\n position: {\n /** 1-indexed target line. */\n line: number\n /** 1-indexed target column. */\n column: number\n }\n text: string\n }\n | {\n /** Language-aware import insertion. */\n kind: 'insert_import'\n /** Complete language-native import statement to add, e.g. \"import { foo } from 'bar'\", \"from app import value\", or \"use crate::value\". */\n importStatement: string\n }\n | {\n /** Language-aware import removal. */\n kind: 'remove_import'\n /** Complete language-native import statement to remove. Required unless moduleSpecifier is provided. */\n importStatement?: string\n /** Module specifier to remove imports from, e.g. \"react\" or \"./helper\". */\n moduleSpecifier?: string\n }\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'create'\n /** Exact bytes to write to the new file. */\n content: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'delete'\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'move'\n /** New project-relative path. The destination must be absent. */\n destinationPath: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'replace_range'\n readCapability: string\n startLine?: number\n endLine?: number\n occurrence?: {\n match: string\n occurrence?: number\n }\n newContent: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'rewrite_symbol'\n symbol: string\n content: string\n occurrence?: number\n /** Optional cap.v3 copied from the matching read_files symbol slice. It authorizes exactly the symbol and its contiguous preceding comment block. */\n readCapability?: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'patch'\n diff: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'write_file'\n content: string\n /** Optional whole-file-covering cap.v3 from a fresh complete whole-file read. Only a full-file capability with a hash matching current content may authorize overwrite; partial ranges never authorize write_file. */\n basedOnRead?: string\n }\n )[]\n}\n\n/**\n * Parameters for edit_3d_asset tool\n */\nexport interface Edit3dAssetParams {\n /** Project-relative .blend path. */\n path: string\n /** Exact source hash returned by inspect_3d_asset. */\n source_hash: string\n operations: (\n | {\n type: 'rename_object'\n object: string\n new_name: string\n }\n | {\n type: 'set_object_transform'\n object: string\n location?: any[]\n rotation_degrees?: any[]\n scale?: any[]\n }\n | {\n type: 'set_render_resolution'\n width: number\n height: number\n percentage?: number\n }\n | {\n type: 'set_frame_range'\n start: number\n end: number\n }\n )[]\n}\n\n/**\n * Find several files related to a brief natural language description of the files or the name of a function or class you are looking for.\n */\nexport interface FindFilesParams {\n /** A brief natural language description of the files or the name of a function or class you are looking for. It's also helpful to mention a directory or two to look within. */\n prompt: string\n}\n\n/**\n * List unique file paths whose content matches a pattern, with optional symbol grouping. Built on top of ripgrep (rg).\n */\nexport interface FindFilesMatchingContentParams {\n /** Regex pattern (ripgrep syntax) to match file content against. */\n pattern: string\n /** Optional safe ripgrep flags as one string or argv tokens. Allowed: -i/--ignore-case, -S/--smart-case, -s/--case-sensitive, -w/--word-regexp, -F/--fixed-strings, -U/--multiline, --multiline-dotall, -g/--glob, -t/--type, -T/--type-not. Examples: \"-g *.ts -g *.tsx\" or [\"-g\", \"*.ts\", \"-g\", \"*.tsx\"]. Do not quote the entire expression inside the JSON string. Output-shape flags such as -c/--count, --count-matches, -l, -v/--invert-match, context -A/-B/-C, -r/--replace, --exec, and -z/--null are rejected (this tool forces -l or --json itself). Redundant -n/--line-number inputs are ignored. */\n flags?: string | string[]\n /** Optional working directory or single file to search within, relative to the project root or absolute. Absolute paths may be outside the project. A directory becomes ripgrep's cwd and scopes the search under that path (plus existing blessed hidden dirs); a file scopes the search to that file only (process cwd = project root when the file is under the project, else the file's parent). Defaults to the project root. */\n cwd?: string\n /** Maximum number of unique files to return. Defaults to 100. */\n maxFiles?: number\n /** When true, also return the names of the top-level symbols (functions, classes, methods, exports, constants) that contain each match, plus the per-file match count. Symbol extraction is heuristic and works best for JS/TS/Python/Go/Rust source files; languages without a recognized declaration shape produce an empty symbols list. */\n groupBySymbol?: boolean\n /** Maximum seconds to let ripgrep run before returning partial results. Defaults to 15. */\n timeoutSeconds?: number\n}\n\n/**\n * Read-only git status and (optionally) diff for the current project.\n */\nexport interface GitStatusParams {\n /** When true, also return the unified diff of uncommitted changes. */\n include_diff?: boolean\n /** When true with include_diff, returns the staged diff instead of unstaged. */\n staged?: boolean\n /** Optional path to scope status/diff to (relative to project root). */\n path?: string\n /** Maximum characters of diff output to return. Defaults to 40,000. */\n max_chars?: number\n}\n\n/**\n * Create a new git branch, optionally switching to it. Refuses to branch when the working tree is dirty unless `allow_dirty` is true.\n */\nexport interface GitBranchParams {\n /** Name of the branch to create. Must start with an alphanumeric character and contain only [a-zA-Z0-9._/-]. */\n branch_name: string\n /** When true (default), create AND switch to the branch (`git checkout -b`). When false, only create the branch (`git branch`), leaving the current branch checked out. */\n switch?: boolean\n /** When true, skip the dirty-tree refusal check. Defaults to false — the tool refuses to branch when the working tree has uncommitted changes. */\n allow_dirty?: boolean\n}\n\n/**\n * Parameters for get_task tool\n */\nexport interface GetTaskParams {\n /** Optional plan session slug. Defaults to .agents/ACTIVE_SESSION. */\n session?: string\n}\n\n/**\n * Parameters for get_change_review_bundle tool\n */\nexport interface GetChangeReviewBundleParams {\n max_chars?: number\n}\n\n/**\n * Inspect the current repository/worktree identity and Git state without modifying it.\n */\nexport interface InspectWorkspaceParams {}\n\n/**\n * Parameters for inspect_environment tool\n */\nexport interface InspectEnvironmentParams {}\n\n/**\n * Parameters for inspect_3d_asset tool\n */\nexport interface Inspect3dAssetParams {\n /** Project-relative 3D asset path. */\n path: string\n}\n\n/**\n * Parameters for get_affected_tests tool\n */\nexport interface GetAffectedTestsParams {\n files: string[]\n}\n\n/**\n * Parameters for get_build_targets tool\n */\nexport interface GetBuildTargetsParams {\n files: string[]\n}\n\n/**\n * Parameters for inspect_codebase_structure tool\n */\nexport interface InspectCodebaseStructureParams {\n scope?: string[]\n}\n\n/**\n * Parameters for inspect_feature_completeness tool\n */\nexport interface InspectFeatureCompletenessParams {\n feature: string\n snapshot_id: string\n scope?: string[]\n}\n\n/**\n * Parameters for evaluate_audit_coverage tool\n */\nexport interface EvaluateAuditCoverageParams {\n snapshot_id: string\n structural_receipts: {\n schema_version: 1\n snapshot_id: string\n shard_id: string\n subsystem_ids: string[]\n files: string[]\n domains: (\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n )[]\n }[]\n features: {\n schema_version: 1\n snapshot_id: string\n feature: string\n evidence_kind: 'heuristic' | 'verified'\n evidence: {\n entrypoints: string[]\n implementation: string[]\n consumers: string[]\n tests: string[]\n docs: string[]\n failure_states: string[]\n }\n }[]\n out_of_scope?: {\n id: string\n reason: string\n }[]\n scope?: string[]\n}\n\n/**\n * Search for files matching a glob pattern. Returns matching file paths sorted by modification time (newest first, then path for deterministic ties).\n */\nexport interface GlobParams {\n /** Glob pattern to match files against (e.g., *.js, src/glob/*.ts, glob/test/glob/*.go). */\n pattern: string\n /** Optional working directory or file path, relative to project root. If a directory, the glob pattern is matched against paths relative to this cwd, while returned files remain project-relative. If a file path, the pattern is matched against that file only (full path or basename). If not provided, searches from project root. */\n cwd?: string\n}\n\n/**\n * Cancel a background job started by run_terminal_command.\n */\nexport interface KillJobParams {\n /** The jobId returned by run_terminal_command with process_type: BACKGROUND. */\n jobId: string\n /** Signal to send. Defaults to SIGTERM; use SIGKILL only if graceful termination fails. */\n signal?: 'SIGTERM' | 'SIGKILL'\n}\n\n/**\n * List files and directories in the specified path. Returns separate arrays of file names and directory names.\n */\nexport interface ListDirectoryParams {\n /** Directory path to list, relative to the project root. */\n path: string\n}\n\n/**\n * List this run's background jobs (shell processes and background agents, running and settled) with statuses, bucketed pending process/log output relative to the last check_job consumer cursor (agents usually show pending: 'none'), and a gap flag.\n */\nexport interface ListJobsParams {}\n\n/**\n * Retrieve information about an agent by ID\n */\nexport interface LookupAgentInfoParams {\n /** Agent ID (short local or full published format) */\n agentId: string\n}\n\n/**\n * Query the local codebase graph index to find relevant files ranked by symbol names, imports, headings, paths, doc concepts, and graph relationships. The index is built automatically on startup.\n */\nexport interface QueryIndexParams {\n /** Natural language query or keyword terms describing the files you are looking for. Optional for graph modes when from/to paths are provided. For example: \"authentication\", \"database migrations\", \"editor mutation logic\", \"React components\". */\n query?: string\n /** Maximum number of results to return. Defaults to 20. */\n limit?: number\n /** Optional list of file extensions to filter results (without dot). E.g. [\"ts\", \"tsx\"] for TypeScript only. */\n fileTypes?: string[]\n /** Optional normalized project-relative directory prefixes. Results outside every prefix are excluded before ranking/limiting. */\n pathPrefixes?: string[]\n /** search|explain|neighbors|path|commands|references — see tool description. */\n mode?: 'search' | 'neighbors' | 'path' | 'explain' | 'commands' | 'references'\n /** Optional source file path for neighbors, path, and references modes. */\n from?: string\n /** Optional target file path for path mode. Also used as the seed file for references mode when from is omitted or not indexed. */\n to?: string\n}\n\n/**\n * Fetch up-to-date documentation for libraries and frameworks using Context7 API.\n */\nexport interface ReadDocsParams {\n /** The library or framework name (e.g., \"Next.js\", \"MongoDB\", \"React\"). Use the official name as it appears in documentation if possible. Only public libraries available in Context7's database are supported, so small or private libraries may not be available. */\n libraryTitle: string\n /** Specific topic to focus on (e.g., \"routing\", \"hooks\", \"authentication\") */\n topic: string\n /** Optional maximum number of tokens to return. Defaults to 10000. Values less than 10000 are automatically increased to 10000. */\n max_tokens?: number\n}\n\n/**\n * Read multiple files from disk and return their contents. Use this tool to read as many files as would be helpful to answer the user's request.\n */\nexport interface ReadFilesParams {\n /** Whole-file paths to read. Complete results include editAnchor.readCapability for follow-up edits. */\n paths?: string[]\n /** 1-indexed inclusive line ranges. Sole `paths` entry infers missing path. */\n ranges?: {\n /** Project-relative file path. */\n path: string\n /** 1-indexed inclusive start line. Defaults to 1. */\n startLine?: number\n /** 1-indexed inclusive end line. Defaults to the last line. */\n endLine?: number\n }[]\n /** Contiguous line windows; each complete window mints a scoped cap.v3 editAnchor. */\n windows?: {\n /** File path to read in contiguous line windows, relative to the project root. */\n path: string\n /** Lines per window. Defaults to 400, capped at 5000. */\n windowSize?: number\n /** 1-indexed window number to return. Omit to get the window manifest (totalLines, windowSize, windowCount) plus the first window. */\n window?: number\n }[]\n /** Literal-anchored context blocks with a scoped cap.v3 editAnchor per block. */\n around?: {\n /** File path to read a content-anchored block from, relative to the project root. */\n path: string\n /** Exact literal string to anchor on. Robust to line-number drift. */\n match: string\n /** 1-indexed occurrence of `match` to anchor on. Defaults to 1. */\n occurrence?: number\n /** Lines of context to include on each side of the match, clamped at file boundaries. Defaults to 40, capped at 2000. */\n contextLines?: number\n }[]\n /** Nth top-level symbol by name (rewrite_symbol occurrence semantics); prefer batch `symbols` when possible. */\n symbol?: {\n /** File path to extract a symbol slice from, relative to the project root. */\n path: string\n /** Top-level symbol name (function, class, interface, method) to pull, as shown by read_outline. */\n name: string\n /** When multiple top-level symbols share this name, the 1-indexed one to return. Defaults to 1. Matches rewrite_symbol occurrence semantics. */\n occurrence?: number\n }[]\n /** Named symbol slices with editAnchors; prefer over full reads when names are known. */\n symbols?: {\n /** Project-relative file path. */\n path: string\n /** Symbol names to slice. */\n names: string[]\n }[]\n}\n\n/**\n * Read image files from disk and return them as model-visible image media.\n */\nexport interface ReadImageParams {\n /** List of image file paths to read. */\n paths: string[]\n}\n\n/**\n * Parameters for render_3d_preview tool\n */\nexport interface Render3dPreviewParams {\n /** Project-relative 3D asset path. */\n path: string\n views?: ('camera' | 'perspective' | 'front' | 'side' | 'top')[]\n mode?: 'material' | 'clay' | 'wireframe'\n width?: number\n height?: number\n}\n\n/**\n * Read the last N lines from a log/text file or background job log without starting a background tail process.\n */\nexport interface ReadLogsParams {\n /** Path to the log file, relative to the project root unless absolute. Required unless jobId is provided. */\n path?: string\n /** Background job id returned by run_terminal_command(process_type: BACKGROUND). When provided, reads the job log file directly. */\n jobId?: string\n /** Number of trailing lines to read. Defaults to 200. */\n lines?: number\n /** Maximum characters to return. Defaults to 20,000. */\n max_chars?: number\n}\n\n/**\n * Generate an outline of imports, exports, classes, methods, and function signatures in a source file without reading the entire implementation.\n */\nexport interface ReadOutlineParams {\n /** File path to generate the AST-like outline for, relative to the project root. */\n path: string\n}\n\n/**\n * Read one or more directory subtrees (as a blob including subdirectories, file names, and parsed variables within each source file) or return parsed variable names for files. If no paths are provided, returns the entire project tree.\n */\nexport interface ReadSubtreeParams {\n /** List of paths to directories or files. Relative to the project root. If omitted, the entire project tree is used. */\n paths?: string[]\n /** Maximum token budget for the subtree blob; the tree will be truncated to fit within this budget by first dropping file variables and then removing the most-nested files and directories. */\n maxTokens?: number\n}\n\n/**\n * Replace all of, a contained sub-range of, or the Nth literal occurrence inside content observed through one fresh cap.v3 read capability.\n */\nexport interface ReplaceRangeParams {\n /** The path to the file to edit. */\n path: string\n /** Copy the cap.v3 readCapability verbatim from the matching fresh read_files editAnchor. The token supplies the observed line bounds and content hash. */\n readCapability: string\n /** Optional 1-indexed target start within the capability-covered range. Omit with endLine to replace the complete observed range. */\n startLine?: number\n /** Optional 1-indexed target end within the capability-covered range. Omit with startLine to replace the complete observed range. */\n endLine?: number\n /** Optional occurrence targeting: replace the 1-indexed occurrence (default 1) of the exact literal match found inside the capability-authorized range. Mutually exclusive with startLine/endLine. */\n occurrence?: {\n match: string\n occurrence?: number\n }\n /** Complete replacement content for the selected line range. */\n newContent: string\n}\n\n/**\n * Replace a whole symbol's definition by name using the file's syntax tree, without copying its current text. Resolves the exact AST range and applies it through the safe str_replace path (atomic, anchored).\n */\nexport interface RewriteSymbolParams {\n /** File path containing the symbol, relative to the project root. */\n path: string\n /** Name of the function/class/method/type/interface to replace (as shown by read_outline). */\n symbol: string\n /** The complete new source for the symbol, replacing its entire current definition (e.g. the whole function including its signature and body). Provide REAL newlines/tabs in the string — literal backslash-n (\\n) and backslash-t (\\t) sequences are not interpreted and will be written verbatim into the file. This matches str_replace. */\n content: string\n /** When multiple top-level symbols share this name, the 1-indexed one to replace. */\n occurrence?: number\n /** Optional cap.v3 copied from the matching read_files symbol slice. Under strict read-before-edit this authorizes exactly the symbol and its contiguous preceding comment block. */\n readCapability?: string\n}\n\n/**\n * Render a small interactive UI widget in the Openbuff CLI. Currently supports a button that opens a link.\n */\nexport interface RenderUiParams {\n /** The UI widget to render. */\n widget: {\n /** Widget type. Currently, the only supported widget is button. */\n type: 'button'\n /** Short button label shown to the user. */\n text: string\n /** The http:// or https:// URL to open when the user clicks the button. */\n link: string\n /** Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions. */\n variant?: 'primary' | 'secondary'\n }\n}\n\n/**\n * Parameters for run_file_change_hooks tool\n */\nexport interface RunFileChangeHooksParams {\n /** List of file paths that were changed and should trigger file change hooks */\n files: string[]\n}\n\n/**\n * Parameters for run_targeted_validation tool\n */\nexport interface RunTargetedValidationParams {\n snapshot_id: string\n files: string[]\n artifact_kinds?: string[]\n}\n\n/**\n * Execute a CLI command from the **project root** (different from the user's cwd).\n */\nexport interface RunTerminalCommandParams {\n /** CLI command valid for user's OS. */\n command: string\n /** SYNC (default) for finite commands that exit: waits and returns output. BACKGROUND only for long-running or never-exiting processes (dev servers, watchers, log tails): starts a detached job and returns a jobId immediately so the turn is not blocked. Live job_update already drives the user UI; use check_job for agent-side readiness/exitCode/join, not solely for user progress. */\n process_type?: 'SYNC' | 'BACKGROUND'\n /** For BACKGROUND commands only: keep the job running if the owning request is cancelled. Defaults to false. */\n detach?: boolean\n /** The working directory to run the command in. Default is the project root. */\n cwd?: string\n /** Set to -1 for no timeout. Does not apply for BACKGROUND commands. Default 30 */\n timeout_seconds?: number\n /** Runtime-managed background job owner; agents must omit. */\n owner?: {\n clientSessionId: string\n rootRunId: string\n parentRunId: string\n parentAgentId: string\n }\n}\n\n/**\n * Atomically replace conversation history and, when supplied, commit a validated structured task-memory revision.\n */\nexport interface SetMessagesParams {\n messages: any\n taskMemory?: {\n schemaVersion: 1\n goal?: string\n requirements?: string[]\n decisions?: string[]\n filesInspected?: string[]\n editsMade?: string[]\n validationResults?: string[]\n reviewReceipts?: string[]\n blockers?: string[]\n nextActions?: string[]\n historicalSummary?: string\n evidence?: {\n id: string\n kind:\n | 'requirement'\n | 'decision'\n | 'read'\n | 'edit'\n | 'validation'\n | 'review'\n | 'blocker'\n | 'handoff'\n | 'note'\n summary: string\n source?: string\n path?: string\n freshnessHash?: string\n workspaceRevision?: number\n verifiedAt?: number\n supersedes?: string[]\n stale?: boolean\n }[]\n workspaceRevision?: number\n workspaceSnapshotId?: string\n }\n expectedTaskMemoryRevision?: number\n}\n\n/**\n * JSON object to set as the agent output. The shape of the parameters are specified dynamically further down in the conversation. This completely replaces any previous output. If the agent was spawned, this value will be passed back to its parent. If the agent has an outputSchema defined, the output will be validated against it.\n */\nexport interface SetOutputParams {\n data?: Record\n [key: string]: any\n}\n\n/**\n * Load a skill by name to get its full instructions. Skills provide reusable behaviors and instructions.\n */\nexport interface SkillParams {\n /** The name of the skill to load */\n name: string\n}\n\n/**\n * Spawn up to 12 agents and send a prompt and/or parameters to each of them. These agents will run in parallel. Note that that means they will run independently. Split larger work into bounded waves. If you need to run agents sequentially, use spawn_agents with one agent at a time instead.\n */\nexport interface SpawnAgentsParams {\n agents: {\n /** Agent to spawn. Must be a name from the live \"You can spawn the following agents\" catalog (hyphenated ids; underscores accepted). */\n agent_type: string\n /** Prompt to send to the agent */\n prompt?: string\n /** If true, return jobId immediately and run as in-process coroutine; poll with check_background_agent. Defaults to false (blocking). Cannot outlive this CLI session. */\n background?: boolean\n /** Optional structured handoff; additive — non-consumers still get prompt/params. */\n handoff?:\n | {\n schemaVersion: 1\n taskId: string\n role:\n | 'orchestrator'\n | 'explorer'\n | 'thinker'\n | 'editor'\n | 'repair-editor'\n | 'test-writer'\n | 'doc-writer'\n | 'dependency-manager'\n | 'debugger'\n | 'validator'\n | 'reviewer'\n | 'security-reviewer'\n | 'committer'\n | 'synthesizer'\n | 'specialist'\n | 'general'\n objective: string\n requirements: {\n id: string\n text: string\n required: boolean\n }[]\n acceptanceCriteria: {\n id: string\n behavior: string\n verification: string\n }[]\n context:\n | {\n path: string\n symbols: string[]\n reason: string\n confidence: 'confirmed' | 'inferred' | 'unknown'\n freshnessHash?: string\n workspaceRevision?: number\n }[]\n | Record\n | string\n currentBehavior?: string\n desiredBehavior?: string\n invariants?: string[]\n nonGoals: string[]\n risks?: string[]\n unknowns?: string[]\n findings: {\n id: string\n text: string\n files: string[]\n snapshotFingerprint: string\n }[]\n permissions: {\n readablePaths: string[]\n writablePaths: string[]\n allowedTools: string[]\n }\n workspaceRevision?: number\n workspaceSnapshotId?: string\n summary?: string\n artifacts?: string[]\n successCriteria?: string[]\n constraints?: string[]\n }\n | Record\n /** Optional wall-clock deadline seconds; omit or -1 for none. Agent defaultTimeoutMs still applies when set. */\n timeout_seconds?: number\n /** Parameters object for the agent */\n params?: {\n /** Terminal command to run (basher, tmux-cli) */\n command?: string\n /** What information from the command output is desired (basher) */\n what_to_summarize?: string\n /** Timeout for command. Set to -1 for no timeout. Default 30 (basher) */\n timeout_seconds?: number\n /** Save full command output to a /tmp log and extract failure lines for long SYNC command output (basher) */\n save_full_log?: boolean\n /** grep -E failure extraction pattern used with save_full_log (basher) */\n failure_pattern?: string\n /** Maximum extracted failure lines to return with save_full_log (basher) */\n max_failure_lines?: number\n /** Array of code search queries (code-searcher) */\n searchQueries?: {\n /** The pattern to search for */\n pattern: string\n /** Optional ripgrep flags as one string or argv tokens (e.g. \"-i -g *.ts\" or [\"-i\", \"-g\", \"*.ts\"]). Do not quote the entire expression inside the JSON string. */\n flags?: string | string[]\n /** Optional working directory relative to project root */\n cwd?: string\n /** Max results per file. Default 15 */\n maxResults?: number\n }[]\n /** Relevant file paths to read (general-agent) */\n filePaths?: string[]\n /** Relevant directory paths to inventory (general-agent) */\n directoryPaths?: string[]\n /** Directories to search within (file-picker) */\n directories?: string[]\n /** Starting URL to navigate to (browser-use) */\n url?: string\n /** Exact task-owned paths eligible for staging (git-committer) */\n owned_paths?: string[]\n /** Optional branch to create or switch to (git-committer) */\n branch_name?: string\n /** Create and switch to branch_name when true (git-committer) */\n branch_switch?: boolean\n /** Allow branch create/switch on a dirty worktree (git-committer) */\n allow_dirty_branch?: boolean\n /** Push the resulting feature branch when authorized (git-committer) */\n push?: boolean\n /** Remote used for fetch/push (git-committer) */\n remote?: string\n /** Assigned gate snapshot fingerprint (reviewer specialists) */\n snapshot_id?: string\n /** Changed file paths to review (security-reviewer) */\n changed_files?: string[]\n /** Opaque snapshot token to echo (security-reviewer) */\n snapshot_fingerprint?: string\n /** Package manager selected from repository manifests (dependency-manager) */\n manager?: string\n /** Dependency operation: add, remove, sync, restore, or update (dependency-manager) */\n operation?: string\n /** Exact package specifications (dependency-manager) */\n packages?: string[]\n /** Optional workspace selector (dependency-manager) */\n workspace?: string\n /** GitHub repository URL to clone (librarian) */\n repoUrl?: string\n /** Retain the owned /tmp clone after completion (librarian) */\n retainClone?: boolean\n /** Optional search or path patterns */\n patterns?: string[]\n /** Exact files in scope (reviewer specialists) */\n files?: string[]\n /** Optional agent-specific prompts */\n prompts?: string[]\n [key: string]: any\n }\n }[]\n}\n\n/**\n * Parameters for str_replace tool\n */\nexport interface StrReplaceParams {\n /** The file to edit. */\n path: string\n atomic?: boolean\n replacements: {\n oldString: string\n newString: string\n allowMultiple?: boolean\n occurrenceIndex?: number\n /** Optional authenticated cap.v3 readCapability copied verbatim from the matching fresh read_files editAnchor. */\n basedOnRead?: string\n /** For deletion replacements only (newString is empty): treat a missing oldString as an already-applied no-op. Use only for explicit idempotent cleanup retries, never for ordinary edits. When every requested change resolves to such a no-op - every replacement of a standalone str_replace call, or every edit of an edit_transaction - the call succeeds with zero file changes and the skip messages rather than failing. When combined with occurrenceIndex, a partially-applied cleanup also skips: fewer remaining exact occurrences than the requested index means that occurrence is treated as already applied. Only valid when newString is empty; both the input and provider schemas reject any other combination. */\n skipIfMissing?: boolean\n }[]\n}\n\n/**\n * Suggest clickable followup prompts to the user. Each followup becomes a card the user can click to send that prompt.\n */\nexport interface SuggestFollowupsParams {\n /** List of suggested followup prompts the user can click to send */\n followups: {\n /** The full prompt text to send as a user message when clicked */\n prompt: string\n /** Short display label for the card (defaults to truncated prompt if not provided) */\n label?: string\n }[]\n}\n\n/**\n * Signal that the task is complete. Use this tool when:\n- The user's request is completely fulfilled\n- You need clarification from the user before continuing\n- You are stuck or need help from the user to continue\n\nThis tool explicitly marks the end of your work on the current task.\n */\nexport interface TaskCompletedParams {}\n\n/**\n * Deeply consider complex tasks by brainstorming approaches and tradeoffs step-by-step.\n */\nexport interface ThinkDeeplyParams {\n /** Detailed step-by-step analysis. Initially keep each step concise (max ~5-7 words per step). */\n thought: string\n}\n\n/**\n * Parameters for update_plan_status tool\n */\nexport interface UpdatePlanStatusParams {\n /** Artifact path. Must be `.agents/sessions//PLAN.md`, `.agents/sessions//STATUS.md`, or `.agents/sessions//LESSONS.md`. Absolute paths and `..` traversal are rejected. Editing PLAN.md is permitted only for tri-state task toggles (not full overwrites). */\n path: string\n /** Targeted updates applied in order. Each entry rewrites at most one matching checklist line; unmatched updates fall through to `append`. */\n updates?: {\n /** Stable task ID at the start of a checklist line (for example `P2-T3`). Preferred over substring matching. */\n taskId?: string\n /** Substring of the existing task/checklist line to match (case-insensitive). The first matching `- [ ]`/`-[x]`/`-[~]`/`-[/]`/`-[!]` line in the artifact will be updated in place. */\n task?: string\n /** When provided, sets the checkbox state of the matched line (true -> `[x]`, false -> `[ ]`). Ignored when `status` is also provided. */\n completed?: boolean\n /** Explicit tri-state task status. When provided, overrides `completed`. Transitions a task to `in_progress` (`[~]`), `done` (`[x]`), `cancelled` (`[/]`), `blocked` (`[!]`), or back to `pending` (`[ ]`). */\n status?: 'pending' | 'in_progress' | 'done' | 'cancelled' | 'blocked'\n /** Optional short note to append to the matched line in parentheses. Preserves any existing trailing text on the line. */\n note?: string\n }[]\n /** Optional delimited entry appended at the end of the artifact (used when there is no matching task line for the change being recorded). */\n append?: {\n /** Short heading for an appended entry. Used to form a clearly delimited block (`## `). */\n heading: string\n /** Markdown body for the appended entry. Written verbatim under the heading. */\n body: string\n }\n /** Optional session-level status transition. When provided, `.agents/sessions//STATE.json` is created or updated to reflect the new lifecycle status. */\n sessionStatus?:\n | 'draft'\n | 'ready'\n | 'active'\n | 'executing'\n | 'validating'\n | 'reviewing'\n | 'blocked'\n | 'paused'\n | 'completed'\n | 'archived'\n /** Optional current-task pointer written as a `` annotation in PLAN.md. Pass an empty string or omit to clear the pointer. Only takes effect when path targets PLAN.md. */\n currentTask?: string\n /** Optional STATE.json compare-and-swap revision. The update fails without writing when the current revision differs. */\n expectedRevision?: number\n /** Validation or review evidence associated with a stable task ID. Completing a PLAN task requires a passed validation checkpoint with receiptIds. */\n checkpoint?: {\n taskId: string\n phase: 'validation' | 'review'\n passed: boolean\n summary?: string\n receiptIds?: string[]\n }\n}\n\n/**\n * Search the web for current information, or fetch the content of a specific URL.\n */\nexport interface WebSearchParams {\n /** The search query to find relevant web content. Required unless url is provided. */\n query?: string\n /** A specific URL to fetch and read the full text content of. When provided, fetches this page directly instead of searching. Useful for reading documentation, GitHub READMEs, blog posts, or any public web page. */\n url?: string\n /** Search depth - 'standard' for quick results, 'deep' for more comprehensive search. Default is 'standard'. Ignored when url is provided. */\n depth?: 'standard' | 'deep'\n /** When fetching a URL, also extract and return links found on the page. Enables navigation by letting you see what pages are linked. Default: true. */\n include_links?: boolean\n /** Maximum number of links to extract when include_links is true. Default: 40. */\n max_links?: number\n}\n\n/**\n * Create or overwrite a file with the given content.\n */\nexport interface WriteFileParams {\n /** Path to the file relative to the **project root** */\n path: string\n /** What the change is intended to do in only one sentence. */\n instructions: string\n /** Complete file content to write to the file. */\n content: string\n /** Optional whole-file-covering cap.v3 from a fresh complete whole-file read (paths or full-file range). Only a capability that covers the entire current file (startLine=1 through the current line count) with a hash matching current content may authorize overwrite; partial range capabilities never authorize write_file. */\n basedOnRead?: string\n}\n\n/**\n * Parameters for write_audit_findings tool\n */\nexport interface WriteAuditFindingsParams {\n /** Existing durable audit session slug under .agents/sessions/. Accepts only a short identifier token: 1 to 100 characters of letters, digits, dot, underscore, or dash, and neither `.` nor `..` on its own. */\n sessionSlug: string\n /** Unique shard identifier used as the findings filename. Accepts only a short identifier token: 1 to 100 characters of letters, digits, dot, underscore, or dash, and neither `.` nor `..` on its own. */\n shardId: string\n /** Exact snapshotId returned by inspect_codebase_structure, such as its 64-character sha256 digest. Required for a directly composable structuralReceipt; omitted only for legacy callers. Accepts only a short identifier token: 1 to 100 characters of letters, digits, dot, underscore, or dash, and neither `.` nor `..` on its own. When snapshotId and coverage.domains are both present the call receives a structuralReceipt, so coverage.subsystemIds and coverage.files must each name at least one entry: evaluate_audit_coverage rejects a receipt whose subsystem_ids or files list is empty. */\n snapshotId?: string\n /** Each findings entry rejects control and Unicode format characters in title, risk, fix, and evidence — NUL, any other control character, and the U+2028/U+2029 line separators — while still accepting tabs and line breaks in that prose. findings[].path is a location rather than prose, so it must be a single-line value with none of those characters and no tabs or line breaks; it is trimmed, and the trimmed value is the one rendered into the finding heading. */\n findings: {\n severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'\n domain:\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n | 'api-abi'\n path: string\n line?: number\n title: string\n risk: string\n fix: string\n evidence: string\n }[]\n /** Every coverage list must name each entry at most once: a repeated file, subsystemId, featureId, or domain is rejected rather than counted twice. Entries are compared after trimming surrounding whitespace, and the trimmed value is what reaches the artifact and the receipt, so two spellings that differ only in whitespace are the same entry. Every coverage files, subsystemIds, and featureIds entry must be a single-line value: tabs, carriage returns, newlines, NUL, any other control or Unicode format character, and the U+2028/U+2029 line separators are rejected. Entries are trimmed, and the trimmed value is the one uniqueness is judged on. */\n coverage: {\n /** See the coverage description for the single-line hygiene rule, which applies to this list too. See the coverage description for the uniqueness rule, which applies to this list too. */\n subsystemIds: string[]\n /** See the coverage description for the single-line hygiene rule, which applies to this list too. See the coverage description for the uniqueness rule, which applies to this list too. */\n featureIds: string[]\n /** See the coverage description for the single-line hygiene rule, which applies to this list too. See the coverage description for the uniqueness rule, which applies to this list too. */\n files: string[]\n /** coverage.domains accepts canonical domain ids only, so use api-contract there: the legacy api-abi alias is accepted only in findings[].domain. When coverage.domains is present it must name at least one domain: an empty list is rejected rather than treated as an omitted field. See the coverage description for the uniqueness rule, which applies to this list too. */\n domains?: (\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n )[]\n }\n /** Set noIssuesFound=true exactly when findings is empty and false whenever findings is non-empty; any other combination is rejected. */\n noIssuesFound?: boolean\n}\n\n/**\n * Write a todo list to track tasks for multi-step implementations. Use this frequently to maintain an updated step-by-step plan.\n */\nexport interface WriteTodosParams {\n /** List of todos with their completion status. Add ALL of the applicable tasks to the list, so you don't forget to do anything. Try to order the todos the same way you will complete them. Do not mark todos as completed if you have not completed them yet! */\n todos: {\n /** Description of the task */\n task: string\n /** Whether the task is completed */\n completed: boolean\n }[]\n}\n\n/**\n * Get parameters type for a specific tool\n */\nexport type GetToolParams = ToolParamsMap[T]\n" export const utilTypesSource = "// ===== JSON Types =====\nexport type JSONValue =\n | null\n | string\n | number\n | boolean\n | JSONObject\n | JSONArray\n\nexport type JSONObject = { [key: string]: JSONValue }\n\nexport type JSONArray = JSONValue[]\n\n/**\n * JSON Schema definition (for prompt schema or output schema)\n */\nexport type JsonSchema = {\n type?:\n | 'object'\n | 'array'\n | 'string'\n | 'number'\n | 'boolean'\n | 'null'\n | 'integer'\n description?: string\n properties?: Record\n required?: string[]\n enum?: Array\n [k: string]: unknown\n}\nexport type JsonObjectSchema = JsonSchema & { type: 'object' }\n\n// ===== Data Content Types =====\nexport type DataContent = string | Uint8Array | ArrayBuffer | Buffer\n\n// ===== Provider Metadata Types =====\nexport type ProviderMetadata = Record>\n\n// ===== Content Part Types =====\nexport type TextPart = {\n type: 'text'\n text: string\n providerOptions?: ProviderMetadata\n}\n\nexport type ImagePart = {\n type: 'image'\n image: DataContent\n mediaType?: string\n providerOptions?: ProviderMetadata\n}\n\nexport type FilePart = {\n type: 'file'\n data: DataContent\n filename?: string\n mediaType: string\n providerOptions?: ProviderMetadata\n}\n\nexport type ReasoningPart = {\n type: 'reasoning'\n text: string\n providerOptions?: ProviderMetadata\n}\n\nexport type ToolCallPart = {\n type: 'tool-call'\n toolCallId: string\n toolName: string\n input: Record\n providerOptions?: ProviderMetadata\n providerExecuted?: boolean\n}\n\nexport type ToolResultOutput =\n | {\n type: 'json'\n value: JSONValue\n }\n | {\n type: 'media'\n data: string\n mediaType: string\n }\n\n// ===== Message Types =====\nexport type AuxiliaryMessageData = {\n providerOptions?: ProviderMetadata\n tags?: string[]\n\n /** @deprecated Use tags instead. */\n timeToLive?: 'agentStep' | 'userPrompt'\n /** @deprecated Use tags instead. */\n keepDuringTruncation?: boolean\n /** @deprecated Use tags instead. */\n keepLastTags?: string[]\n}\n\nexport type SystemMessage = {\n role: 'system'\n content: TextPart[]\n} & AuxiliaryMessageData\n\nexport type UserMessage = {\n role: 'user'\n content: (TextPart | ImagePart | FilePart)[]\n} & AuxiliaryMessageData\n\nexport type AssistantMessage = {\n role: 'assistant'\n content: (TextPart | ReasoningPart | ToolCallPart)[]\n} & AuxiliaryMessageData\n\nexport type ToolMessage = {\n role: 'tool'\n toolCallId: string\n toolName: string\n content: ToolResultOutput[]\n} & AuxiliaryMessageData\n\nexport type Message =\n | SystemMessage\n | UserMessage\n | AssistantMessage\n | ToolMessage\n\n// ===== MCP Server Types =====\n\n/**\n * MCP server configuration for stdio-based servers.\n *\n * Environment variables in `env` can be:\n * - A plain string value (hardcoded, e.g., `'production'`)\n * - A `$VAR_NAME` reference to read from local environment (e.g., `'$NOTION_TOKEN'`)\n *\n * The `$VAR_NAME` syntax reads from `process.env.VAR_NAME` at agent load time.\n * This keeps secrets out of your agent definitions - store them in `.env.local` instead.\n *\n * @example\n * ```typescript\n * env: {\n * // Read NOTION_TOKEN from local .env file\n * NOTION_TOKEN: '$NOTION_TOKEN',\n * // Read MY_API_KEY from local env, pass as API_KEY to MCP server\n * API_KEY: '$MY_API_KEY',\n * // Hardcoded value (non-secret)\n * NODE_ENV: 'production',\n * }\n * ```\n */\nexport type MCPConfig =\n | {\n type?: 'stdio'\n command: string\n args?: string[]\n env?: Record\n }\n | {\n type?: 'http' | 'sse'\n url: string\n params?: Record\n headers?: Record\n }\n\n// ============================================================================\n// Logger Interface\n// ============================================================================\nexport interface Logger {\n debug: (data: any, msg?: string) => void\n info: (data: any, msg?: string) => void\n warn: (data: any, msg?: string) => void\n error: (data: any, msg?: string) => void\n}\n" diff --git a/common/src/templates/initial-agents-dir/types/tools.ts b/common/src/templates/initial-agents-dir/types/tools.ts index f154211fe7..70d9a894f0 100644 --- a/common/src/templates/initial-agents-dir/types/tools.ts +++ b/common/src/templates/initial-agents-dir/types/tools.ts @@ -1157,12 +1157,13 @@ export interface WriteFileParams { * Parameters for write_audit_findings tool */ export interface WriteAuditFindingsParams { - /** Existing durable audit session slug under .agents/sessions/. */ + /** Existing durable audit session slug under .agents/sessions/. Accepts only a short identifier token: 1 to 100 characters of letters, digits, dot, underscore, or dash, and neither `.` nor `..` on its own. */ sessionSlug: string - /** Unique shard identifier used as the findings filename. */ + /** Unique shard identifier used as the findings filename. Accepts only a short identifier token: 1 to 100 characters of letters, digits, dot, underscore, or dash, and neither `.` nor `..` on its own. */ shardId: string - /** Exact snapshotId returned by inspect_codebase_structure. Required for a directly composable structuralReceipt; omitted only for legacy callers. */ + /** Exact snapshotId returned by inspect_codebase_structure, such as its 64-character sha256 digest. Required for a directly composable structuralReceipt; omitted only for legacy callers. Accepts only a short identifier token: 1 to 100 characters of letters, digits, dot, underscore, or dash, and neither `.` nor `..` on its own. When snapshotId and coverage.domains are both present the call receives a structuralReceipt, so coverage.subsystemIds and coverage.files must each name at least one entry: evaluate_audit_coverage rejects a receipt whose subsystem_ids or files list is empty. */ snapshotId?: string + /** Each findings entry rejects control and Unicode format characters in title, risk, fix, and evidence — NUL, any other control character, and the U+2028/U+2029 line separators — while still accepting tabs and line breaks in that prose. findings[].path is a location rather than prose, so it must be a single-line value with none of those characters and no tabs or line breaks; it is trimmed, and the trimmed value is the one rendered into the finding heading. */ findings: { severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' domain: @@ -1182,10 +1183,15 @@ export interface WriteAuditFindingsParams { fix: string evidence: string }[] + /** Every coverage list must name each entry at most once: a repeated file, subsystemId, featureId, or domain is rejected rather than counted twice. Entries are compared after trimming surrounding whitespace, and the trimmed value is what reaches the artifact and the receipt, so two spellings that differ only in whitespace are the same entry. Every coverage files, subsystemIds, and featureIds entry must be a single-line value: tabs, carriage returns, newlines, NUL, any other control or Unicode format character, and the U+2028/U+2029 line separators are rejected. Entries are trimmed, and the trimmed value is the one uniqueness is judged on. */ coverage: { + /** See the coverage description for the single-line hygiene rule, which applies to this list too. See the coverage description for the uniqueness rule, which applies to this list too. */ subsystemIds: string[] + /** See the coverage description for the single-line hygiene rule, which applies to this list too. See the coverage description for the uniqueness rule, which applies to this list too. */ featureIds: string[] + /** See the coverage description for the single-line hygiene rule, which applies to this list too. See the coverage description for the uniqueness rule, which applies to this list too. */ files: string[] + /** coverage.domains accepts canonical domain ids only, so use api-contract there: the legacy api-abi alias is accepted only in findings[].domain. When coverage.domains is present it must name at least one domain: an empty list is rejected rather than treated as an omitted field. See the coverage description for the uniqueness rule, which applies to this list too. */ domains?: ( | 'security' | 'correctness' @@ -1197,6 +1203,7 @@ export interface WriteAuditFindingsParams { | 'api-contract' )[] } + /** Set noIssuesFound=true exactly when findings is empty and false whenever findings is non-empty; any other combination is rejected. */ noIssuesFound?: boolean } diff --git a/common/src/tools/params/tool/write-audit-findings.ts b/common/src/tools/params/tool/write-audit-findings.ts index df0daaa3a9..f15d1ffb92 100644 --- a/common/src/tools/params/tool/write-audit-findings.ts +++ b/common/src/tools/params/tool/write-audit-findings.ts @@ -18,18 +18,68 @@ export const auditFindingDomainSchema = z value === 'api-abi' ? ('api-contract' as const) : value, ) +// Shared rationale for every control/format rejection below (finding text, +// coverage entries, snapshotId): the SDK writer's `singleLine` only collapses +// CR/LF, but the parsed value is echoed verbatim into the `structuralReceipt` +// fields evaluate_audit_coverage consumes, so the whole class — NUL, other +// C0/C1 controls, Unicode format characters, and U+2028/U+2029 — is rejected +// at this boundary instead. + +/** + * Applies that rejection to the finding text fields, but keeps tabs and line + * breaks: finding prose legitimately wraps and the writer collapses line + * endings before rendering. + */ +const findingTextSchema = (maxLength: number) => + z + .string() + .min(1) + .max(maxLength) + .regex( + /^(?:[^\p{Cc}\p{Cf}\u2028\u2029]|[\t\n\r])+$/u, + 'Remove control and Unicode format characters other than tabs and line breaks', + ) + +/** + * Single-line hygiene for the values that name a location: every coverage list + * entry and `findings[].path`. Trims so the value uniqueness was judged on is + * the one that reaches the Markdown bullet, the finding heading, and + * `structuralReceipt`. The length bound is checked on the raw value and + * non-emptiness on the trimmed one, so a whitespace-only entry cannot trim + * down to an empty coverage claim. + */ +const coverageEntrySchema = (maxLength: number) => + z + .string() + .max(maxLength) + .regex( + /^[^\p{Cc}\p{Cf}\u2028\u2029]+$/u, + 'Use a single-line coverage entry without tabs, line breaks, or other control characters', + ) + .transform((value) => value.trim()) + .pipe(z.string().min(1)) + export const auditFindingSchema = z.object({ severity: auditFindingSeveritySchema, domain: auditFindingDomainSchema, - path: z.string().min(1).max(500), + // A finding location is never wrapped prose, so it is held to the same + // single-line rule as `coverage.files` instead of `findingTextSchema`: a tab + // or line break here would reach the artifact's finding heading. + path: coverageEntrySchema(500), line: z.number().int().positive().optional(), - title: z.string().min(1).max(300), - risk: z.string().min(1).max(2_000), - fix: z.string().min(1).max(2_000), - evidence: z.string().min(1).max(4_000), + title: findingTextSchema(300), + risk: findingTextSchema(2_000), + fix: findingTextSchema(2_000), + evidence: findingTextSchema(4_000), }) -const slugSchema = z +/** + * Canonical shape of an audit artifact-path identifier. Exported so callers + * that echo a rejected identifier (e.g. the SDK writer's error path) validate + * against this schema instead of re-implementing its charset, length, and + * dot-segment rules. + */ +export const auditIdentifierSchema = z .string() .min(1) .max(100) @@ -41,32 +91,168 @@ const slugSchema = z message: 'Dot path segments are not valid audit identifiers', }) +/** + * Stated verbatim on every field bound by `auditIdentifierSchema` + * (`sessionSlug`, `shardId`, `snapshotId`): each one becomes a path segment of + * the runtime-derived artifact path, and the rejection message names no rule, + * so the charset, length, and dot-segment bounds are only discoverable here. + */ +export const auditIdentifierRule = + 'Accepts only a short identifier token: 1 to 100 characters of letters, digits, dot, underscore, or dash, and neither `.` nor `..` on its own.' + +/** + * Rule fragments the schema states verbatim on the field each one governs. + * Every parse failure collapses to one generic `Missing or invalid + * write_audit_findings parameters.` message, so these `.describe()` strings are + * the only place a rejected caller can learn the rules; they are exported so + * covering tests pin the rules instead of hand-copied doc substrings. + */ +export const noIssuesFoundRule = + 'Set noIssuesFound=true exactly when findings is empty and false whenever findings is non-empty; any other combination is rejected.' + +export const coverageDomainAliasRule = + 'coverage.domains accepts canonical domain ids only, so use api-contract there: the legacy api-abi alias is accepted only in findings[].domain.' + +/** + * The list is optional, but an explicitly empty one is rejected rather than + * treated as omitted: `[]` would otherwise claim a snapshot-bound call + * evaluated zero domains while still emitting `structuralReceipt`. + */ +export const coverageDomainsNonEmptyRule = + 'When coverage.domains is present it must name at least one domain: an empty list is rejected rather than treated as an omitted field.' + +/** + * Enforced only on snapshot-bound calls, which are the ones that receive + * `structuralReceipt`: evaluate_audit_coverage's structural_receipts schema + * requires non-empty subsystem_ids and files, so an empty list here would + * yield a receipt that tool rejects — contradicting the composability the + * description promises. Legacy calls without snapshotId/domains keep parsing. + */ +export const snapshotCoverageCompletenessRule = + 'When snapshotId and coverage.domains are both present the call receives a structuralReceipt, so coverage.subsystemIds and coverage.files must each name at least one entry: evaluate_audit_coverage rejects a receipt whose subsystem_ids or files list is empty.' + +/** + * Enforced on every coverage list: a repeated entry would inflate the + * artifact's coverage lines, the receipt's counts, and `structuralReceipt`. + * Duplicates are rejected rather than deduped, and `coverageEntrySchema` trims + * each entry before uniqueness is judged, so the compared value is the exact + * value the SDK writer renders into the Markdown bullet and echoes into the + * receipt: two spellings that would collapse into one identical bullet cannot + * both be counted. + */ +export const coverageUniquenessRule = + 'Every coverage list must name each entry at most once: a repeated file, subsystemId, featureId, or domain is rejected rather than counted twice. Entries are compared after trimming surrounding whitespace, and the trimmed value is what reaches the artifact and the receipt, so two spellings that differ only in whitespace are the same entry.' + +/** + * Stated on each coverage list instead of repeating `coverageUniquenessRule` + * there: the rule governs every list, but duplicating its full text per field + * only inflates the tool schema sent to the model. + */ +export const coverageUniquenessCrossReference = + 'See the coverage description for the uniqueness rule, which applies to this list too.' + +/** + * Applies that rejection to the three string coverage lists. Stated in full on + * the parent `coverage` object rather than on each list, and restates the trim + * so a caller reading only that description learns entries are compared after + * trimming. + */ +export const coverageEntryHygieneRule = + 'Every coverage files, subsystemIds, and featureIds entry must be a single-line value: tabs, carriage returns, newlines, NUL, any other control or Unicode format character, and the U+2028/U+2029 line separators are rejected. Entries are trimmed, and the trimmed value is the one uniqueness is judged on.' + +/** + * Stated on each string coverage list instead of repeating + * `coverageEntryHygieneRule` three times, for the same schema-size reason as + * `coverageUniquenessCrossReference`. + */ +export const coverageEntryHygieneCrossReference = + 'See the coverage description for the single-line hygiene rule, which applies to this list too.' + +/** + * Mirrors `coverageEntryHygieneRule` for the finding entries: the two hygiene + * levels differ (prose wraps, a location does not), and neither is + * discoverable from the generic rejection message. + */ +export const findingEntryHygieneRule = + 'Each findings entry rejects control and Unicode format characters in title, risk, fix, and evidence — NUL, any other control character, and the U+2028/U+2029 line separators — while still accepting tabs and line breaks in that prose. findings[].path is a location rather than prose, so it must be a single-line value with none of those characters and no tabs or line breaks; it is trimmed, and the trimmed value is the one rendered into the finding heading.' + +function hasNoDuplicates(values: readonly string[]): boolean { + return new Set(values).size === values.length +} + +/** + * Names the offending list in the issue message so any caller path that does + * surface zod issues points at the rule that failed; the SDK writer collapses + * every parse failure to one generic message, which is why the same rule is + * also advertised on `coverage`'s own description. + */ +function uniqueEntries(field: string) { + return { message: `List each coverage.${field} entry at most once` } +} + const inputSchema = z .object({ - sessionSlug: slugSchema.describe( - 'Existing durable audit session slug under .agents/sessions/.', + sessionSlug: auditIdentifierSchema.describe( + `Existing durable audit session slug under .agents/sessions/. ${auditIdentifierRule}`, ), - shardId: slugSchema.describe( - 'Unique shard identifier used as the findings filename.', + shardId: auditIdentifierSchema.describe( + `Unique shard identifier used as the findings filename. ${auditIdentifierRule}`, ), - snapshotId: z - .string() - .min(1) + // Bounded with the same schema as the artifact-path identifiers because it + // is echoed verbatim into `structuralReceipt.snapshot_id`. + snapshotId: auditIdentifierSchema .optional() .describe( - 'Exact snapshotId returned by inspect_codebase_structure. Required for a directly composable structuralReceipt; omitted only for legacy callers.', + `Exact snapshotId returned by inspect_codebase_structure, such as its 64-character sha256 digest. Required for a directly composable structuralReceipt; omitted only for legacy callers. ${auditIdentifierRule} ${snapshotCoverageCompletenessRule}`, ), - findings: z.array(auditFindingSchema).max(100), - coverage: z.object({ - subsystemIds: z.array(z.string().min(1).max(200)).max(100), - featureIds: z.array(z.string().min(1).max(200)).max(100), - files: z.array(z.string().min(1).max(500)).max(500), - domains: z.array(auditCoverageDomainSchema).min(1).optional(), - }), - noIssuesFound: z.boolean().default(false), + findings: z + .array(auditFindingSchema) + .max(100) + .describe(findingEntryHygieneRule), + coverage: z + .object({ + // Both the uniqueness and hygiene rules are enforced on every string + // coverage list but stated in full on the parent `coverage` object, so + // these fields only point at them instead of tripling ~60 words of + // hygiene text into the tool schema. + subsystemIds: z + .array(coverageEntrySchema(200)) + .max(100) + .refine(hasNoDuplicates, uniqueEntries('subsystemIds')) + .describe( + `${coverageEntryHygieneCrossReference} ${coverageUniquenessCrossReference}`, + ), + featureIds: z + .array(coverageEntrySchema(200)) + .max(100) + .refine(hasNoDuplicates, uniqueEntries('featureIds')) + .describe( + `${coverageEntryHygieneCrossReference} ${coverageUniquenessCrossReference}`, + ), + files: z + .array(coverageEntrySchema(500)) + .max(500) + .refine(hasNoDuplicates, uniqueEntries('files')) + .describe( + `${coverageEntryHygieneCrossReference} ${coverageUniquenessCrossReference}`, + ), + domains: z + .array(auditCoverageDomainSchema) + .min(1) + .refine(hasNoDuplicates, uniqueEntries('domains')) + .optional() + .describe( + `${coverageDomainAliasRule} ${coverageDomainsNonEmptyRule} ${coverageUniquenessCrossReference}`, + ), + }) + .describe(`${coverageUniquenessRule} ${coverageEntryHygieneRule}`), + // Defaults to false so a shard that reports nothing must attest to that + // explicitly; `noIssuesFoundRule` on the field spells out the accepted + // combinations, since the rejection message names no rule. + noIssuesFound: z.boolean().default(false).describe(noIssuesFoundRule), }) .superRefine((input, ctx) => { - if (input.noIssuesFound === input.findings.length > 0) { + if (input.noIssuesFound !== (input.findings.length === 0)) { ctx.addIssue({ code: 'custom', path: ['noIssuesFound'], @@ -74,6 +260,21 @@ const inputSchema = z 'Set noIssuesFound=true only when findings is empty; otherwise set it to false.', }) } + // Only these calls emit `structuralReceipt`, and only the two lists it + // carries are checked: evaluate_audit_coverage's strict schema requires + // non-empty subsystem_ids/files and has no featureIds field, so an empty + // featureIds list still composes. + if (input.snapshotId && input.coverage.domains) { + for (const field of ['subsystemIds', 'files'] as const) { + if (input.coverage[field].length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['coverage', field], + message: `List at least one coverage.${field} entry when snapshotId and coverage.domains are set: evaluate_audit_coverage rejects the resulting structuralReceipt with an empty list`, + }) + } + } + } }) export const auditFindingsReceiptSchema = z.object({ @@ -112,7 +313,7 @@ const toolName = 'write_audit_findings' export const writeAuditFindingsParams = { toolName, endsAgentStep: false, - description: `Persist one audit shard's structured findings to a runtime-owned Markdown artifact. The path is derived as .agents/sessions//findings/.md; callers cannot choose another path. New audit flows must copy the exact inspect_codebase_structure snapshotId into snapshotId and explicitly list every evaluated coverage domain; the result then includes structuralReceipt for direct use with evaluate_audit_coverage. Legacy calls without both fields remain accepted but do not receive that attestation. Return only the compact receipt after writing—do not repeat findings in prose.`, + description: `Persist one audit shard's structured findings to a runtime-owned Markdown artifact. The path is derived as .agents/sessions//findings/.md; callers cannot choose another path. New audit flows must copy the exact inspect_codebase_structure snapshotId into snapshotId and explicitly list every evaluated coverage domain; the result then includes structuralReceipt for direct use with evaluate_audit_coverage. Legacy calls without both fields remain accepted but do not receive that attestation. Every rejection returns one generic message, so read the field descriptions of noIssuesFound and coverage for the rules they enforce. Return only the compact receipt after writing—do not repeat findings in prose.`, inputSchema, outputSchema: jsonToolResultSchema( z.union([auditFindingsReceiptSchema, auditFindingsErrorSchema]), diff --git a/packages/agent-runtime/src/run-agent-step.ts b/packages/agent-runtime/src/run-agent-step.ts index 0dc9e5a1e9..d86459c913 100644 --- a/packages/agent-runtime/src/run-agent-step.ts +++ b/packages/agent-runtime/src/run-agent-step.ts @@ -82,6 +82,9 @@ import { commitTaskMemory, compileTaskMemoryContext, deriveTaskMemoryDraftFromMessages, + deriveTaskMemoryFocusPaths, + ensureTaskMemoryGoal, + flushBufferedToolEvidenceIntoTaskMemory, mergeTaskMemoryDraft, } from './util/task-memory' @@ -445,10 +448,10 @@ export const runAgentStep = async ( // consistent stopping point (resumable next turn via persisted run state) // instead of being cut off mid-edit when stepsRemaining hits 0. if (agentState.stepsRemaining === NEAR_STEP_CAP_WARNING_THRESHOLD) { - const hasWriteTodos = - getEffectiveAgentToolNames(agentTemplate, agentState).includes( - 'write_todos', - ) + const hasWriteTodos = getEffectiveAgentToolNames( + agentTemplate, + agentState, + ).includes('write_todos') const warningMessage = hasWriteTodos ? NEAR_STEP_CAP_WARNING_MESSAGE : NEAR_STEP_CAP_WARNING_MESSAGE_NO_WRITE_TODOS @@ -796,6 +799,30 @@ export const runAgentStep = async ( toolResults: newToolResults, }) + // Single step-scoped task-memory commit for this agent's own reads/edits. + // The tool executor only derives and buffers evidence per result (model tool + // calls in one step run concurrently), so this is the one writer for the + // step: no two calls can derive the same revision, and the whole-memory + // normalize+checksum runs once per step instead of once per tool result. + try { + const nextTaskMemory = flushBufferedToolEvidenceIntoTaskMemory({ + owner: agentState, + current: agentState.taskMemory, + workspaceState: agentState.workspaceState, + }) + // Identity result means every derived entry was already stored, so the + // commit was skipped and assigning would be a no-op write. + if (nextTaskMemory && nextTaskMemory !== agentState.taskMemory) { + agentState.taskMemory = nextTaskMemory + } + } catch (error) { + // Best-effort bookkeeping: recording evidence must never fail the step. + logger.debug( + { error, agentId: agentState.agentId }, + 'Failed to record buffered tool evidence in task memory', + ) + } + agentState.messageHistory = expireMessages( agentState.messageHistory, 'agentStep', @@ -1693,6 +1720,10 @@ export async function loopAgentSteps( agentType: state.agentType, contextWindowTokens: state.contextWindowTokens, rootAgent: !state.parentId, + // Rank evidence toward the files this run has just read or + // edited, so relevance rather than raw recency decides what + // survives the compiled budget. + focusPaths: deriveTaskMemoryFocusPaths(state.taskMemory), }), ), tags: ['TASK_MEMORY_CONTEXT'], @@ -1822,6 +1853,63 @@ export async function loopAgentSteps( } } + // Capture the request goal once per step for the root agent. The + // compaction branch below scrapes only when a + // session actually compacts, so a session that never compacts used to + // persist a record with an empty goal. Derivation matches that branch's + // boundedGoal exactly so both paths record the same text. + // + // Deliberately after the programmatic step: a `set_messages`-yielding + // generator (the context pruner) guards its transcript replacement with + // `expectedTaskMemoryRevision`, and its view of the persisted revision + // is injected by template id. Creating a revision-0 record before that + // step makes the guard fail, so semantic compaction would be rejected + // and the run would fall back to the mechanical emergency brake. The + // compiled memory message is rebuilt below, so the goal still reaches + // this step's request. + if (!currentAgentState.parentId) { + try { + let derivedGoal = '' + for ( + let index = currentAgentState.messageHistory.length - 1; + index >= 0; + index-- + ) { + const message = currentAgentState.messageHistory[index] + if (message.role !== 'user') continue + const plainText = message.content + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join('\n') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (!plainText || /^(?:\/compact|compact)$/i.test(plainText)) { + continue + } + derivedGoal = plainText + break + } + const nextTaskMemory = ensureTaskMemoryGoal({ + current: currentAgentState.taskMemory, + goal: derivedGoal, + workspaceState: currentAgentState.workspaceState, + }) + // Identity result means the goal was already captured: assigning + // would be a no-op write, so only a genuinely new value is stored. + if ( + nextTaskMemory && + nextTaskMemory !== currentAgentState.taskMemory + ) { + currentAgentState.taskMemory = nextTaskMemory + } + } catch (error) { + // Memory bookkeeping is best-effort: a revision conflict or schema + // rejection must never abort the agent step. + logger.debug({ error }, 'Failed to capture task memory goal') + } + } + // Programmatic orchestrators (notably Base2) get the first opportunity // to run semantic compaction. Rebuild the request from the resulting // history before applying the deterministic emergency brake. diff --git a/packages/agent-runtime/src/tools/tool-executor.ts b/packages/agent-runtime/src/tools/tool-executor.ts index 4e2b5b5aed..c5851687b2 100644 --- a/packages/agent-runtime/src/tools/tool-executor.ts +++ b/packages/agent-runtime/src/tools/tool-executor.ts @@ -54,6 +54,10 @@ import { validateAndGetAgentTemplate, } from './handlers/tool/spawn-agent-utils' import { getAgentTemplate } from '../templates/agent-registry' +import { + bufferToolEvidenceForStep, + recordToolEvidenceInTaskMemory, +} from '../util/task-memory' import { ensureZodSchema } from './prompts' import type { AgentTemplate } from '../templates/types' @@ -230,7 +234,9 @@ export function normalizeNativeToolOutput(params: { issues: [{ message: 'mutation result exceeded cheap input bounds' }], } } - const parsed = toolParams[params.toolName].outputSchema.safeParse(params.output) + const parsed = toolParams[params.toolName].outputSchema.safeParse( + params.output, + ) if (parsed.success) { if (getToolMetadata(params.toolName).resultContract === 'mutation_v1') { const mutationPart = params.output.find( @@ -259,7 +265,9 @@ export function normalizeNativeToolOutput(params: { ) { return { valid: false, - output: jsonToolResult(reconciled.mutation) as CodebuffToolOutput, + output: jsonToolResult( + reconciled.mutation, + ) as CodebuffToolOutput, issues: [ { message: @@ -271,7 +279,9 @@ export function normalizeNativeToolOutput(params: { if (reconciled.mutation.outcome !== 'unconfirmed') { return { valid: true, - output: jsonToolResult(reconciled.mutation) as CodebuffToolOutput, + output: jsonToolResult( + reconciled.mutation, + ) as CodebuffToolOutput, issues: [], } } @@ -406,9 +416,7 @@ export function normalizeNativeToolOutput(params: { if (raw && typeof raw === 'object' && !Array.isArray(raw)) { const record = raw as Record const operationId = - typeof record.operationId === 'string' - ? record.operationId - : undefined + typeof record.operationId === 'string' ? record.operationId : undefined if (operationId) { const reconciled = reconcileFileMutationResultV1({ lifecycle: { @@ -522,7 +530,8 @@ function parseStringifiedToolInput( parsed = parseJsonStringWithRepair(stringInput) parseError = undefined } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + const errorMessage = + error instanceof Error ? error.message : String(error) if (detectTransportTruncation(stringInput, errorMessage)) { sawTransportTruncation = true } @@ -547,7 +556,12 @@ function parseStringifiedToolInput( } } - return { input: parsed, parseError, sawTransportTruncation, truncationRecovery } + return { + input: parsed, + parseError, + sawTransportTruncation, + truncationRecovery, + } } function detectHeredocPayload(rawInput: unknown): string | undefined { @@ -648,10 +662,7 @@ function repairTerminalCommandScalars( // "soon"/""/"NaN"/"Infinity"/"6e2" fail closed. if (typeof record.timeout_seconds === 'string') { const trimmed = record.timeout_seconds.trim() - if ( - /^-?\d+(?:\.\d+)?$/.test(trimmed) && - Number.isFinite(Number(trimmed)) - ) { + if (/^-?\d+(?:\.\d+)?$/.test(trimmed) && Number.isFinite(Number(trimmed))) { copy = copy ?? { ...record } copy.timeout_seconds = Number(trimmed) } @@ -1044,11 +1055,7 @@ function levenshteinDistanceForToolSuggestion(a: string, b: string): number { curr[0] = i for (let j = 1; j < cols; j += 1) { const cost = a[i - 1] === b[j - 1] ? 0 : 1 - curr[j] = Math.min( - prev[j] + 1, - curr[j - 1] + 1, - prev[j - 1] + cost, - ) + curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost) } const next = prev prev = curr @@ -1073,7 +1080,10 @@ function suggestClosestToolName( if (best === undefined) return undefined // Only suggest a genuinely close match; scale with the longer name so short // names need a tight match and longer names tolerate a couple edits. - const threshold = Math.max(2, Math.floor(Math.max(attempted.length, best.length) / 3)) + const threshold = Math.max( + 2, + Math.floor(Math.max(attempted.length, best.length) / 3), + ) return bestDistance <= threshold ? best : undefined } @@ -1106,7 +1116,10 @@ export function buildUnavailableToolMessage(params: { // This stays message-only; the tool remains fail-closed and nothing is // auto-spawned. When the rejected input carried an explicit pattern, bake // that exact string into the spawn recipe instead of a placeholder. - if (toolName === 'code_search' || toolName === 'find_files_matching_content') { + if ( + toolName === 'code_search' || + toolName === 'find_files_matching_content' + ) { if (availableTools.includes('code_search')) { return `${base} Use the granted \`code_search\` tool directly (pattern/flags/cwd/maxResults). For multi-query batching, spawn code-searcher with params.searchQueries.` } @@ -1170,7 +1183,7 @@ function getFieldSpecificHint( if (paths.has('basedOnRead') || fieldNames.has('basedOnRead')) { return [ 'Hint: `basedOnRead` must be an authenticated cap.v3 readCapability token string returned by read_files. Object-form anchors and wrapped objects like { "$text": "..." } are not accepted.', - 'Copy the `editAnchor.readCapability` value verbatim from the matching fresh read_files result.' + 'Copy the `editAnchor.readCapability` value verbatim from the matching fresh read_files result.', ].join('\n') } @@ -1270,7 +1283,7 @@ function getToolValidationHint( [ 'For replace_range, pass one authenticated cap.v3 readCapability copied from a fresh read_files editAnchor.', 'Omit startLine/endLine to replace the full observed range, or provide both to target a contained sub-range within that capability.', - 'Never pass expectedHash or other separate hash fields.' + 'Never pass expectedHash or other separate hash fields.', ].join('\n'), ) } @@ -1305,7 +1318,9 @@ function getToolValidationHint( typeof editType === 'string' && !(TRANSACTION_EDIT_TYPES as readonly string[]).includes(editType) ) { - namedBadTypes.push(`edits[${index}].type ${JSON.stringify(editType)}`) + namedBadTypes.push( + `edits[${index}].type ${JSON.stringify(editType)}`, + ) } } } @@ -1351,7 +1366,9 @@ function formatInvalidInputExcerpts( const handoffIssues = issues.filter(isSpawnAgentHandoffIssue) if (handoffIssues.length > 0) { const labels = new Set( - handoffIssues.map((issue) => `agents[${String(issue.path?.[1])}].handoff`), + handoffIssues.map( + (issue) => `agents[${String(issue.path?.[1])}].handoff`, + ), ) return [...labels] .map( @@ -1674,7 +1691,10 @@ function collectCustomInputStrings( out: string[], depth: number = 0, ): void { - if (depth > MAX_CUSTOM_INPUT_SCAN_DEPTH || out.length >= MAX_CUSTOM_INPUT_SCAN_STRINGS) { + if ( + depth > MAX_CUSTOM_INPUT_SCAN_DEPTH || + out.length >= MAX_CUSTOM_INPUT_SCAN_STRINGS + ) { return } if (typeof value === 'string') { @@ -2090,8 +2110,9 @@ export async function executeToolCall( // later tool-call batches in the same step cannot run non-terminal work. // Only set when the gate system is active so custom agents stay free. if (gateSystemActive) { - ;(agentState as { suggestFollowupsEmitted?: boolean }) - .suggestFollowupsEmitted = true + ;( + agentState as { suggestFollowupsEmitted?: boolean } + ).suggestFollowupsEmitted = true } } @@ -2466,11 +2487,17 @@ export async function executeToolCall( } if (serialized.length > MAX_SINGLE_AGENT_PAYLOAD_CHARS) { const agentType = - agent && typeof agent === 'object' && typeof (agent as Record).agent_type === 'string' + agent && + typeof agent === 'object' && + typeof (agent as Record).agent_type === 'string' ? String((agent as Record).agent_type) : 'unknown' logger.warn( - { agentType, serializedLength: serialized.length, limit: MAX_SINGLE_AGENT_PAYLOAD_CHARS }, + { + agentType, + serializedLength: serialized.length, + limit: MAX_SINGLE_AGENT_PAYLOAD_CHARS, + }, 'spawn_agents entry exceeds the soft payload size limit; the transport may truncate it. Consider authoring large file bodies with write_file/edit_transaction and running them via a short basher command.', ) } @@ -2848,6 +2875,55 @@ export async function executeToolCall( toolResults.push(toolResult) + // Record this agent's own reads/edits as task-memory evidence. Only child + // agents report through mergeAgentReceiptIntoTaskMemory, so without this the + // root agent's exploration is never remembered. This runs for whichever + // agentState owns the call (child agents record their own tool calls here), + // and never reaches into parentAgentState, so nothing double-counts. + // + // Two writer semantics, chosen so no evidence can be silently lost: + // - Model-emitted calls run CONCURRENTLY within one step, so each result is + // only derived and buffered here; run-agent-step performs exactly one + // commit per step via flushBufferedToolEvidenceIntoTaskMemory. A single + // writer per step means two calls can never both derive revision N+1 and + // have the second assignment clobber the first, and the whole-memory + // normalize+checksum runs once instead of once per tool result. + // - Programmatic (handleSteps) calls are awaited one at a time, so they are + // their own single writer and commit immediately against the live + // agentState.taskMemory. + try { + if (fromHandleSteps) { + const nextTaskMemory = recordToolEvidenceInTaskMemory({ + current: agentState.taskMemory, + toolName, + callId: toolCall.toolCallId, + output: validatedOutput, + workspaceState: agentState.workspaceState, + }) + // Identity result means the derived evidence is byte-identical to what + // is already stored, so the commit was skipped: assigning would be a + // no-op write and only a genuinely new revision is stored. + if (nextTaskMemory && nextTaskMemory !== agentState.taskMemory) { + agentState.taskMemory = nextTaskMemory + } + } else { + bufferToolEvidenceForStep({ + owner: agentState, + toolName, + callId: toolCall.toolCallId, + output: validatedOutput, + workspaceState: agentState.workspaceState, + }) + } + } catch (error) { + // Best-effort bookkeeping: a malformed payload (or a revision conflict on + // the sequential programmatic path) must never fail the tool call. + logger.debug( + { error, toolName, toolCallId: toolCall.toolCallId }, + 'Failed to record tool evidence in task memory', + ) + } + if (!excludeToolFromMessageHistory) { toolResultsToAddToMessageHistory.push(toolResult) } diff --git a/packages/agent-runtime/src/util/__tests__/task-memory.test.ts b/packages/agent-runtime/src/util/__tests__/task-memory.test.ts index 6b44bf90c0..14f25e17ea 100644 --- a/packages/agent-runtime/src/util/__tests__/task-memory.test.ts +++ b/packages/agent-runtime/src/util/__tests__/task-memory.test.ts @@ -1,11 +1,17 @@ import { describe, expect, test } from 'bun:test' +import { createInitialWorkspaceState } from '@codebuff/common/types/workspace-state' import { + bufferToolEvidenceForStep, commitTaskMemory, compileTaskMemoryContext, deriveTaskMemoryDraftFromMessages, + deriveTaskMemoryFocusPaths, + ensureTaskMemoryGoal, + flushBufferedToolEvidenceIntoTaskMemory, mergeAgentReceiptIntoTaskMemory, mergeTaskMemoryDraft, + recordToolEvidenceInTaskMemory, } from '../task-memory' const draft = { @@ -23,6 +29,31 @@ const draft = { evidence: [], } +const readFilesOutput = (entries: { path: string; contentHash: string }[]) => [ + { + type: 'json' as const, + value: { + kind: 'read_files_result', + version: 1, + status: 'ok', + results: entries.map((entry, requestIndex) => ({ + selector: 'file', + requestIndex, + path: entry.path, + status: 'ok', + complete: true, + template: false, + editAnchor: { + startLine: 1, + endLine: 40, + contentHash: entry.contentHash, + readCapability: `cap.v3.${entry.path}`, + }, + })), + }, + }, +] + describe('task memory', () => { test('commits revisions transactionally and rejects stale writers', () => { const first = commitTaskMemory({ @@ -119,6 +150,377 @@ describe('task memory', () => { expect(JSON.stringify(parsed.evidence)).not.toContain('stale file contents') }) + test('keeps hash-verified path evidence across workspace revisions', () => { + const compileWithStale = (stale: boolean) => { + const memory = commitTaskMemory({ + draft: { + ...draft, + workspaceRevision: 20, + workspaceSnapshotId: 'workspace-20', + evidence: [ + { + id: 'hash-verified-read', + kind: 'read' as const, + summary: 'hash-verified contents of src/a.ts', + path: 'src/a.ts', + stale, + workspaceRevision: 3, + }, + ], + }, + expectedRevision: -1, + }) + const compiled = compileTaskMemoryContext({ memory }) + const json = compiled.match( + /[\s\S]*?\n(\{[\s\S]*\})\n<\/task_memory>/, + )?.[1] + expect(json).toBeDefined() + return JSON.stringify(JSON.parse(json!).evidence) + } + + // Reconciliation hashed this file against disk, so the revision counter + // must not override its verdict. + expect(compileWithStale(false)).toContain( + 'hash-verified contents of src/a.ts', + ) + expect(compileWithStale(true)).not.toContain( + 'hash-verified contents of src/a.ts', + ) + }) + + test('still guards pathless evidence by workspace revision', () => { + const memory = commitTaskMemory({ + draft: { + ...draft, + workspaceRevision: 20, + workspaceSnapshotId: 'workspace-20', + evidence: [ + { + id: 'pathless-read', + kind: 'read' as const, + summary: 'unverifiable observation without a path', + workspaceRevision: 3, + }, + ], + }, + expectedRevision: -1, + }) + const compiled = compileTaskMemoryContext({ memory }) + expect(compiled).not.toContain('unverifiable observation without a path') + }) + + test('per-kind evidence caps stop review churn evicting reads', () => { + const reads = Array.from({ length: 5 }, (_, index) => ({ + id: `read-${index}`, + kind: 'read' as const, + summary: `read evidence ${index}`, + path: `src/read-${index}.ts`, + verifiedAt: index + 1, + })) + const reviews = Array.from({ length: 300 }, (_, index) => ({ + id: `review-${index}`, + kind: 'review' as const, + summary: `review evidence ${index}`, + verifiedAt: 1_000 + index, + })) + const memory = commitTaskMemory({ + draft: { ...draft, evidence: [...reads, ...reviews] }, + expectedRevision: -1, + }) + + const storedReviews = memory.evidence.filter( + (item) => item.kind === 'review', + ) + const storedReads = memory.evidence.filter((item) => item.kind === 'read') + expect(storedReviews).toHaveLength(32) + expect(storedReviews[storedReviews.length - 1]!.id).toBe('review-299') + expect(storedReads.map((item) => item.id)).toEqual( + reads.map((item) => item.id), + ) + }) + + test('focusPaths reorders evidence selection toward the requested files', () => { + const memory = commitTaskMemory({ + draft: { + ...draft, + evidence: [ + { + id: 'target-read', + kind: 'read' as const, + summary: 'target file behavior', + path: 'src/target.ts', + verifiedAt: 1, + }, + ...Array.from({ length: 8 }, (_, index) => ({ + id: `unrelated-${index}`, + kind: 'read' as const, + summary: `unrelated observation ${index}`, + path: `src/unrelated-${index}.ts`, + verifiedAt: 100 + index, + })), + ], + }, + expectedRevision: -1, + }) + const compileParams = { + memory, + agentType: 'editor', + contextWindowTokens: 8_000, + rootAgent: false, + } + const evidenceOf = (compiled: string) => { + const json = compiled.match( + /[\s\S]*?\n(\{[\s\S]*\})\n<\/task_memory>/, + )?.[1] + expect(json).toBeDefined() + return JSON.stringify(JSON.parse(json!).evidence) + } + + expect(evidenceOf(compileTaskMemoryContext(compileParams))).not.toContain( + 'target file behavior', + ) + expect( + evidenceOf( + compileTaskMemoryContext({ + ...compileParams, + focusPaths: ['src/target.ts'], + }), + ), + ).toContain('target file behavior') + }) + + test('focus matching ignores near-miss filenames and honors directory prefixes', () => { + const memory = commitTaskMemory({ + draft: { + ...draft, + evidence: [ + { + id: 'read:src/a.tsx', + kind: 'read' as const, + summary: 'Read src/a.tsx lines 1-40', + path: 'src/a.tsx', + verifiedAt: 1, + }, + { + id: 'read:src/nested/child.ts', + kind: 'read' as const, + summary: 'Read src/nested/child.ts lines 1-40', + path: 'src/nested/child.ts', + verifiedAt: 2, + }, + ...Array.from({ length: 8 }, (_, index) => ({ + id: `unrelated-${index}`, + kind: 'note' as const, + summary: `unrelated note ${index}`, + verifiedAt: 100 + index, + })), + ], + }, + expectedRevision: -1, + }) + const compileParams = { + memory, + agentType: 'editor', + contextWindowTokens: 8_000, + rootAgent: false, + } + const evidenceOf = (compiled: string) => { + const json = compiled.match( + /[\s\S]*?\n(\{[\s\S]*\})\n<\/task_memory>/, + )?.[1] + expect(json).toBeDefined() + return JSON.stringify(JSON.parse(json!).evidence) + } + + // `src/a.ts` must not score `src/a.tsx`: substring matching used to promote + // the near-miss filename into the compiled block. + expect( + evidenceOf( + compileTaskMemoryContext({ + ...compileParams, + focusPaths: ['src/a.ts'], + }), + ), + ).not.toContain('src/a.tsx') + + // A segment-aware directory prefix still scores the files beneath it. + expect( + evidenceOf( + compileTaskMemoryContext({ + ...compileParams, + focusPaths: ['src/nested'], + }), + ), + ).toContain('src/nested/child.ts') + }) + + test('empty focusPaths compiles identically to omitting focusPaths', () => { + const memory = commitTaskMemory({ + draft: { + ...draft, + evidence: [ + { + id: 'read-a', + kind: 'read' as const, + summary: 'observation about src/a.ts', + path: 'src/a.ts', + verifiedAt: 2, + }, + { + id: 'decision-a', + kind: 'decision' as const, + summary: 'chose the bounded compiler', + verifiedAt: 1, + }, + ], + }, + expectedRevision: -1, + now: 5, + }) + expect(compileTaskMemoryContext({ memory, focusPaths: [] })).toBe( + compileTaskMemoryContext({ memory }), + ) + }) + + test('derived focus paths pull request-relevant evidence into the compiled block', () => { + const memory = commitTaskMemory({ + draft: { + ...draft, + evidence: [ + { + id: 'validation-target', + kind: 'validation' as const, + summary: 'bun test failed for src/target.ts', + verifiedAt: 1, + }, + ...Array.from({ length: 6 }, (_, index) => ({ + id: `note-${index}`, + kind: 'note' as const, + summary: `unrelated note ${index}`, + verifiedAt: 10 + index, + })), + { + id: 'read:src/target.ts', + kind: 'read' as const, + summary: 'Read src/target.ts lines 1-40', + path: 'src/target.ts', + freshnessHash: 'target', + verifiedAt: 100, + }, + ], + }, + expectedRevision: -1, + }) + + // Only read/edit evidence names a file the request is working on. + expect(deriveTaskMemoryFocusPaths(memory)).toEqual(['src/target.ts']) + + const compileParams = { + memory, + agentType: 'editor', + contextWindowTokens: 8_000, + rootAgent: false, + } + const evidenceOf = (compiled: string) => { + const json = compiled.match( + /[\s\S]*?\n(\{[\s\S]*\})\n<\/task_memory>/, + )?.[1] + expect(json).toBeDefined() + return JSON.stringify(JSON.parse(json!).evidence) + } + + // Recency alone spends the small evidence budget on the newest unrelated + // notes; the focus paths the production caller derives keep the validation + // evidence about the file actually under work. + expect(evidenceOf(compileTaskMemoryContext(compileParams))).not.toContain( + 'bun test failed for src/target.ts', + ) + expect( + evidenceOf( + compileTaskMemoryContext({ + ...compileParams, + focusPaths: deriveTaskMemoryFocusPaths(memory), + }), + ), + ).toContain('bun test failed for src/target.ts') + }) + + test('deriveTaskMemoryFocusPaths ignores stale and pathless evidence', () => { + expect(deriveTaskMemoryFocusPaths(undefined)).toEqual([]) + const memory = commitTaskMemory({ + draft: { + ...draft, + evidence: [ + { + id: 'read:src/stale.ts', + kind: 'read' as const, + summary: 'Read src/stale.ts lines 1-10', + path: 'src/stale.ts', + stale: true, + verifiedAt: 3, + }, + { + id: 'decision-1', + kind: 'decision' as const, + summary: 'chose the bounded compiler', + verifiedAt: 2, + }, + { + id: 'edit:src/live.ts', + kind: 'edit' as const, + summary: 'update src/live.ts', + path: 'src/live.ts', + freshnessHash: 'live', + verifiedAt: 1, + }, + ], + }, + expectedRevision: -1, + }) + expect(deriveTaskMemoryFocusPaths(memory)).toEqual(['src/live.ts']) + }) + + test('repeat reads of an unchanged file cost no revision', () => { + const first = recordToolEvidenceInTaskMemory({ + toolName: 'read_files', + callId: 'call-read-1', + output: readFilesOutput([ + { path: 'src/a.ts', contentHash: 'sha256:same' }, + ]), + }) + expect(first).toBeDefined() + + // Byte-identical derived evidence returns the same object, so the caller + // skips the write entirely. + expect( + recordToolEvidenceInTaskMemory({ + current: first, + toolName: 'read_files', + callId: 'call-read-2', + output: readFilesOutput([ + { path: 'src/a.ts', contentHash: 'sha256:same' }, + ]), + }), + ).toBe(first) + + // A changed hash is genuinely new evidence and still commits. + const changed = recordToolEvidenceInTaskMemory({ + current: first, + toolName: 'read_files', + callId: 'call-read-3', + output: readFilesOutput([ + { path: 'src/a.ts', contentHash: 'sha256:changed' }, + ]), + }) + expect(changed!.revision).toBe(first!.revision + 1) + // Stored without the `sha256:` prefix, matching what the SDK store's + // `hashFile` produces and compares against. + expect( + changed!.evidence.find((item) => item.id === 'read:src/a.ts')! + .freshnessHash, + ).toBe('changed') + }) + test('imports legacy knowledge blocks without making them authoritative chat', () => { const derived = deriveTaskMemoryDraftFromMessages({ messages: [ @@ -264,6 +666,337 @@ describe('task memory', () => { }) }) + test('ensureTaskMemoryGoal captures the goal once and never burns repeat revisions', () => { + // Nothing observed and nothing stored: no record is worth creating. + expect(ensureTaskMemoryGoal({ goal: ' ' })).toBeUndefined() + + const withoutGoal = commitTaskMemory({ + draft: { ...draft, goal: '' }, + expectedRevision: -1, + now: 1, + }) + const captured = ensureTaskMemoryGoal({ + current: withoutGoal, + goal: ' Capture the goal outside compaction ', + workspaceState: createInitialWorkspaceState(0), + }) + expect(captured?.goal).toBe('Capture the goal outside compaction') + expect(captured?.revision).toBe(withoutGoal.revision + 1) + expect(captured?.workspaceRevision).toBe(0) + expect(captured?.workspaceSnapshotId).toBe('workspace.v1.0.00000000') + // Other fields survive the goal-only commit. + expect(captured?.requirements).toEqual(withoutGoal.requirements) + + // Repeat steps must reuse the same object so no revision is spent. + expect( + ensureTaskMemoryGoal({ current: captured, goal: 'A different phrasing' }), + ).toBe(captured) + }) + + test('recordToolEvidenceInTaskMemory records root-level reads', () => { + const memory = recordToolEvidenceInTaskMemory({ + toolName: 'read_files', + callId: 'call-read-1', + output: readFilesOutput([ + { path: 'src/a.ts', contentHash: 'sha256:aaa' }, + { path: 'src/b.ts', contentHash: 'sha256:bbb' }, + ]), + }) + expect(memory).toBeDefined() + const reads = memory!.evidence.filter((item) => item.kind === 'read') + expect(reads).toHaveLength(2) + expect(reads.map((item) => item.path)).toEqual(['src/a.ts', 'src/b.ts']) + // The runtime producer emits `sha256:` but the consumer (`hashFile` in + // sdk/src/services/task-memory-store.ts) emits a bare hex digest, so the + // prefix is stripped at record time or every entry reconciles stale. + expect(reads.map((item) => item.freshnessHash)).toEqual(['aaa', 'bbb']) + expect(memory!.filesInspected).toEqual(['src/a.ts', 'src/b.ts']) + }) + + test('records only complete whole-file reads, never range/symbol slices', () => { + const memory = recordToolEvidenceInTaskMemory({ + toolName: 'read_files', + callId: 'call-read-mixed', + output: [ + { + type: 'json', + value: { + kind: 'read_files_result', + version: 1, + status: 'ok', + results: [ + { + selector: 'file', + requestIndex: 0, + path: 'src/whole.ts', + status: 'ok', + complete: true, + template: false, + editAnchor: { + startLine: 1, + endLine: 40, + contentHash: 'sha256:whole', + readCapability: 'cap.v3.whole', + }, + }, + { + // A range anchor hashes only the slice, so the store's + // whole-file digest could never match it. + selector: 'range', + requestIndex: 1, + path: 'src/slice.ts', + status: 'ok', + complete: true, + startLine: 10, + endLine: 20, + totalLines: 400, + editAnchor: { + startLine: 10, + endLine: 20, + contentHash: 'sha256:slice', + readCapability: 'cap.v3.slice', + }, + }, + ], + }, + }, + ], + }) + expect(memory!.evidence.map((item) => item.path)).toEqual(['src/whole.ts']) + expect(memory!.filesInspected).toEqual(['src/whole.ts']) + }) + + test('per-loop caps keep a full read payload from starving the mutation loop', () => { + const reads = Array.from({ length: 40 }, (_, index) => ({ + path: `src/read-${index}.ts`, + contentHash: `sha256:read-${index}`, + })) + const memory = recordToolEvidenceInTaskMemory({ + toolName: 'read_files', + callId: 'call-both-kinds', + output: [ + ...readFilesOutput(reads), + { + type: 'json', + value: { + kind: 'file_mutation_result', + version: 1, + operationId: 'op-both', + outcome: 'applied', + actions: [ + { + actionId: 'a-0', + index: 0, + action: 'update', + path: 'src/edited.ts', + outcome: 'applied', + beforeHash: 'sha256:before', + afterHash: 'sha256:after', + }, + ], + }, + }, + ], + }) + const readEntries = memory!.evidence.filter((item) => item.kind === 'read') + // The read loop stops at its own 32-entry cap... + expect(readEntries).toHaveLength(32) + // ...and the mutation loop still records, because the caps are per loop + // rather than shared across the combined evidence length. + expect( + memory!.evidence.filter((item) => item.kind === 'edit')[0]!.path, + ).toBe('src/edited.ts') + }) + + test('one step-scoped commit covers every buffered tool result', () => { + const owner = {} + bufferToolEvidenceForStep({ + owner, + toolName: 'read_files', + callId: 'call-read-a', + output: readFilesOutput([ + { path: 'src/a.ts', contentHash: 'sha256:aaa' }, + ]), + }) + bufferToolEvidenceForStep({ + owner, + toolName: 'read_files', + callId: 'call-read-b', + output: readFilesOutput([ + { path: 'src/b.ts', contentHash: 'sha256:bbb' }, + ]), + }) + + const flushed = flushBufferedToolEvidenceIntoTaskMemory({ owner }) + // Concurrent calls in one step used to each commit revision N+1, so the + // second assignment silently clobbered the first call's evidence. One + // commit per step keeps both. + expect(flushed!.revision).toBe(0) + expect(flushed!.evidence.map((item) => item.id)).toEqual([ + 'read:src/a.ts', + 'read:src/b.ts', + ]) + expect(flushed!.filesInspected).toEqual(['src/a.ts', 'src/b.ts']) + + // The buffer is cleared, so a step with no tool evidence commits nothing. + expect( + flushBufferedToolEvidenceIntoTaskMemory({ + owner, + current: flushed, + }), + ).toBeUndefined() + }) + + test('buffering ignores tool results with no derivable evidence', () => { + const owner = {} + bufferToolEvidenceForStep({ + owner, + toolName: 'list_directory', + callId: 'call-list-1', + output: [ + { + type: 'json', + value: { + kind: 'list_directory_result', + version: 1, + entries: [{ path: 'src', type: 'directory' }], + }, + }, + ], + }) + expect(flushBufferedToolEvidenceIntoTaskMemory({ owner })).toBeUndefined() + }) + + test('recordToolEvidenceInTaskMemory records only applied mutation actions', () => { + const memory = recordToolEvidenceInTaskMemory({ + toolName: 'edit_transaction', + callId: 'call-edit-1', + output: [ + { + type: 'json', + value: { + kind: 'file_mutation_result', + version: 1, + operationId: 'op-1', + outcome: 'partial', + actions: [ + { + actionId: 'a-0', + index: 0, + action: 'update', + path: 'src/applied.ts', + outcome: 'applied', + beforeHash: 'sha256:before', + afterHash: 'sha256:after', + }, + { + actionId: 'a-1', + index: 1, + action: 'delete', + path: 'src/removed.ts', + outcome: 'applied', + beforeHash: 'sha256:before', + afterHash: null, + }, + { + actionId: 'a-2', + index: 2, + action: 'update', + path: 'src/skipped.ts', + outcome: 'not_applied', + beforeHash: null, + afterHash: null, + }, + ], + }, + }, + ], + }) + expect(memory).toBeDefined() + const edits = memory!.evidence.filter((item) => item.kind === 'edit') + expect(edits.map((item) => item.path)).toEqual([ + 'src/applied.ts', + 'src/removed.ts', + ]) + expect(edits[0]!.freshnessHash).toBe('after') + // A deleted file has no post-state hash to verify against. + expect(edits[1]!.freshnessHash).toBeUndefined() + expect(memory!.editsMade).toEqual(['src/applied.ts', 'src/removed.ts']) + expect(JSON.stringify(memory!.evidence)).not.toContain('src/skipped.ts') + }) + + test('re-reading a file replaces its stale evidence instead of duplicating it', () => { + const first = recordToolEvidenceInTaskMemory({ + toolName: 'read_files', + callId: 'call-read-1', + output: readFilesOutput([ + { path: 'src/a.ts', contentHash: 'sha256:old' }, + ]), + }) + const second = recordToolEvidenceInTaskMemory({ + current: first, + toolName: 'read_files', + callId: 'call-read-2', + output: readFilesOutput([ + { path: 'src/a.ts', contentHash: 'sha256:new' }, + ]), + }) + const entries = second!.evidence.filter( + (item) => item.id === 'read:src/a.ts', + ) + expect(entries).toHaveLength(1) + expect(entries[0]!.freshnessHash).toBe('new') + expect(second!.filesInspected).toEqual(['src/a.ts']) + }) + + test('recordToolEvidenceInTaskMemory skips tool results with no derivable evidence', () => { + expect( + recordToolEvidenceInTaskMemory({ + toolName: 'list_directory', + callId: 'call-list-1', + output: [ + { + type: 'json', + value: { + kind: 'list_directory_result', + version: 1, + entries: [{ path: 'src', type: 'directory' }], + }, + }, + ], + }), + ).toBeUndefined() + + // Reads without a trustworthy anchor hash are skipped too: a partial slice + // would reconcile as permanently stale. + expect( + recordToolEvidenceInTaskMemory({ + toolName: 'read_files', + callId: 'call-read-partial', + output: [ + { + type: 'json', + value: { + kind: 'read_files_result', + version: 1, + status: 'partial', + results: [ + { + selector: 'file', + requestIndex: 0, + path: 'src/a.ts', + status: 'partial', + complete: false, + template: false, + }, + ], + }, + }, + ], + }), + ).toBeUndefined() + }) + test('merge dedupes repeated list entries', () => { const base = commitTaskMemory({ draft, expectedRevision: -1 }) const merged = mergeTaskMemoryDraft(base, { @@ -275,6 +1008,17 @@ describe('task memory', () => { ).toHaveLength(1) }) + test('the compiled banner scopes its freshness claim to session start', () => { + const memory = commitTaskMemory({ draft, expectedRevision: -1 }) + const banner = compileTaskMemoryContext({ memory }).split('\n')[1]! + // Nothing re-reconciles mid-session, so the banner must not claim that all + // stale evidence is excluded: a read entry recorded before this session + // edited the same file is still compiled in. + expect(banner).toContain('at session start') + expect(banner).toContain('not re-verified') + expect(banner).toContain('verify live files before mutation') + }) + test('compile excludes stale evidence from request context', () => { const memory = commitTaskMemory({ draft: { @@ -333,4 +1077,65 @@ describe('task memory', () => { ) expect(JSON.stringify(decisions)).not.toContain('old-small-marker') }) + + test('buffer drops evidence beyond MAX_BUFFERED_STEP_EVIDENCE', () => { + const owner = {} + // Each buffered call contributes one evidence entry; loop past the 512 + // cap so the drop branch is exercised. The raw buffer holds 512 entries + // (0-511), but the committed evidence is then trimmed by the per-kind + // cap (read: 64), so the flushed result keeps the 64 newest reads. + for (let index = 0; index < 600; index += 1) { + bufferToolEvidenceForStep({ + owner, + toolName: 'read_files', + callId: `call-${index}`, + output: readFilesOutput([ + { path: `src/file-${index}.ts`, contentHash: `sha256:hash-${index}` }, + ]), + }) + } + const flushed = flushBufferedToolEvidenceIntoTaskMemory({ owner }) + expect(flushed).toBeDefined() + // Raw buffer capped at 512, then per-kind cap trims reads to 64 newest. + expect(flushed!.evidence).toHaveLength(64) + expect(flushed!.evidence[0]!.path).toBe('src/file-448.ts') + expect(flushed!.evidence[63]!.path).toBe('src/file-511.ts') + // Buffer is cleared after commit; subsequent flush yields nothing. + expect(flushBufferedToolEvidenceIntoTaskMemory({ owner })).toBeUndefined() + }) + + test('two distinct owners buffer and flush independently', () => { + const ownerA = {} + const ownerB = {} + bufferToolEvidenceForStep({ + owner: ownerA, + toolName: 'read_files', + callId: 'call-a-1', + output: readFilesOutput([{ path: 'src/a.ts', contentHash: 'sha256:aaa' }]), + }) + bufferToolEvidenceForStep({ + owner: ownerB, + toolName: 'read_files', + callId: 'call-b-1', + output: readFilesOutput([{ path: 'src/b.ts', contentHash: 'sha256:bbb' }]), + }) + bufferToolEvidenceForStep({ + owner: ownerA, + toolName: 'read_files', + callId: 'call-a-2', + output: readFilesOutput([ + { path: 'src/a2.ts', contentHash: 'sha256:aaa2' }, + ]), + }) + const flushedA = flushBufferedToolEvidenceIntoTaskMemory({ owner: ownerA }) + const flushedB = flushBufferedToolEvidenceIntoTaskMemory({ owner: ownerB }) + expect(flushedA!.evidence.map((item) => item.path)).toEqual([ + 'src/a.ts', + 'src/a2.ts', + ]) + expect(flushedB!.evidence.map((item) => item.path)).toEqual(['src/b.ts']) + // Each owner's buffer is isolated and cleared independently. + expect(flushBufferedToolEvidenceIntoTaskMemory({ owner: ownerA })).toBeUndefined() + expect(flushBufferedToolEvidenceIntoTaskMemory({ owner: ownerB })).toBeUndefined() + }) }) diff --git a/packages/agent-runtime/src/util/task-memory.ts b/packages/agent-runtime/src/util/task-memory.ts index cea937f7aa..71c340f9ce 100644 --- a/packages/agent-runtime/src/util/task-memory.ts +++ b/packages/agent-runtime/src/util/task-memory.ts @@ -17,6 +17,38 @@ const ROOT_CONTEXT_CHARS = 36_000 const CHILD_CONTEXT_CHARS = 14_000 const TASK_MEMORY_REVIEW_RECEIPT_MAX_CHARS = 4_000 +// Kept equal to TASK_MEMORY_LIST_CAPS.evidence in +// @codebuff/common/types/task-memory so persisted evidence never exceeds what +// the schema accepts. +const TASK_MEMORY_EVIDENCE_TOTAL_CAP = 256 + +// Per-kind caps intentionally sum above the total cap. Their job is not to +// shrink the budget but to stop one kind from consuming all of it: a single +// reviewer receipt contributes up to 256 `review` entries at once, which used +// to evict every read/edit/decision entry before the global trim ran. +const TASK_MEMORY_EVIDENCE_KIND_CAPS: Record< + TaskMemoryEvidenceV1['kind'], + number +> = { + read: 64, + edit: 64, + requirement: 32, + decision: 32, + validation: 32, + review: 32, + blocker: 32, + handoff: 32, + note: 16, +} + +// Hoisted so the per-item freshness filter allocates nothing per evidence entry. +const REVISION_GUARDED_EVIDENCE_KINDS: ReadonlySet = new Set([ + 'read', + 'edit', + 'validation', + 'review', +]) + function boundText(value: string, maxChars: number): string { const normalized = value.trim() if (normalized.length <= maxChars) return normalized @@ -75,6 +107,14 @@ function serializeReviewReceiptForTaskMemory(receipt: AgentReceipt): string { return typeof id === 'string' && id.trim() ? [boundText(id, 120)] : [] }) .slice(0, 4) + const findingIdCount = findings.filter( + (finding) => + finding && + typeof finding === 'object' && + !Array.isArray(finding) && + typeof (finding as Record).id === 'string' && + ((finding as Record).id as string).trim(), + ).length const requirementCoverage = Array.isArray(review?.requirementCoverage) ? review.requirementCoverage : [] @@ -152,7 +192,7 @@ function serializeReviewReceiptForTaskMemory(receipt: AgentReceipt): string { (Array.isArray(review?.reviewedFiles) ? review.reviewedFiles.length : 0) || - findingIds.length < findings.length || + findingIds.length < findingIdCount || receipt.changedFiles.length > 4 || receipt.unresolved.length > 2 || receipt.requestedValidation.length > 2 || @@ -230,14 +270,32 @@ function normalizeEvidence( const superseded = new Set( [...byId.values()].flatMap((item) => item.supersedes ?? []), ) - return [...byId.values()] + // Sort ascending first so "newest" means the same thing for both trims below. + const sorted = [...byId.values()] .map((item) => superseded.has(item.id) && item.stale !== false ? { ...item, stale: true } : item, ) .sort((a, b) => (a.verifiedAt ?? 0) - (b.verifiedAt ?? 0)) - .slice(-256) + + // Partition the budget by kind before the global trim: a burst of one kind + // (typically reviewer receipts, all sharing one verifiedAt) would otherwise + // fill the whole cap and drop the read/edit/decision entries the next + // session needs. Newest-wins per kind, matching uniqueRecent's iteration. + const perKindCount = new Map() + const kept: TaskMemoryEvidenceV1[] = [] + for (let index = sorted.length - 1; index >= 0; index -= 1) { + const item = sorted[index]! + const used = perKindCount.get(item.kind) ?? 0 + // No `??` fallback: the map is an exhaustive Record over the kind union, so + // a future missing kind must fail typecheck here instead of silently + // picking up a default cap. + if (used >= TASK_MEMORY_EVIDENCE_KIND_CAPS[item.kind]) continue + perKindCount.set(item.kind, used + 1) + kept.unshift(item) + } + return kept.slice(-TASK_MEMORY_EVIDENCE_TOTAL_CAP) } function normalizeDraft(draft: TaskMemoryDraftV1): TaskMemoryDraftV1 { @@ -383,6 +441,423 @@ export function mergeAgentReceiptIntoTaskMemory(params: { }) } +/** + * Captures the request goal outside compaction. `deriveTaskMemoryDraftFromMessages` + * only runs when a session compacts, so a session that never compacts used to + * persist a record with an empty goal — unusable for the next session. + * + * Returns `undefined` when there is nothing worth committing (no goal observed + * and no existing memory), and returns `current` unchanged once a goal is + * already stored so repeat steps burn no revision. + */ +export function ensureTaskMemoryGoal(params: { + current?: TaskMemoryV1 + goal: string + workspaceState?: WorkspaceStateV1 +}): TaskMemoryV1 | undefined { + const { current, workspaceState } = params + const goal = boundText(params.goal, 8_000) + if (!goal && !current) return undefined + // Identity return: the caller compares by reference and skips the write, so + // an already-captured goal costs no revision, checksum, or updatedAt churn. + if (current?.goal) return current + const incoming = taskMemoryDraftV1Schema.parse({ + schemaVersion: 1, + goal, + ...(workspaceState + ? { + workspaceRevision: workspaceState.revision, + workspaceSnapshotId: boundText(workspaceState.snapshotId, 256), + } + : {}), + }) + return commitTaskMemory({ + current, + draft: mergeTaskMemoryDraft(current, incoming), + expectedRevision: current?.revision ?? -1, + }) +} + +// Evidence derived from one tool result is capped defensively, and each loop +// below keeps its OWN counter: one read_files or edit_transaction call can touch +// far more files than a single memory commit should record, and a 32-result +// read payload must never starve the mutation loop of the same output. No tool +// returns both kinds today, but the cap must not silently depend on that. +const TOOL_EVIDENCE_PER_RESULT_CAP = 32 + +/** + * Upper bound on derived entries buffered for one step before further entries + * are dropped, so a runaway step cannot accumulate evidence without limit. The + * per-kind and total caps in `normalizeEvidence` discard the overflow at commit + * time anyway. + */ +const MAX_BUFFERED_STEP_EVIDENCE = 512 + +const FRESHNESS_HASH_PREFIX = 'sha256:' + +/** + * Canonical `freshnessHash` spelling, agreed with the only consumer: `hashFile` + * in sdk/src/services/task-memory-store.ts emits a bare lowercase sha256 hex + * digest with no `sha256:` prefix, and `reconcileTaskMemoryEvidence` compares it + * with `digest === item.freshnessHash`. Runtime producers emit the prefixed + * spelling (`getContentHash` for a read anchor, `getExactContentHash` / + * `hashFileContent` for a mutation `afterHash`), so the prefix is stripped here + * at record time; without that, every entry this path records would reconcile + * `stale: true` on the next session and then be dropped by `evidenceIsFresh`, + * `deriveTaskMemoryFocusPaths`, and `pruneStaleTaskMemoryEvidence`. + * + * Two hash NAMESPACES share that single spelling and must never be conflated, + * which is why the ids below keep separate `read:` / `edit:` prefixes: + * - `read:` stores a whole-file read anchor hash, taken over + * LF-normalized content. It matches the store's raw-byte digest for LF files + * (the canonical committed form here); a CRLF working copy reconciles stale + * rather than falsely fresh. + * - `edit:` stores a mutation `afterHash`, taken over the exact bytes + * just written, so it reconciles byte-for-byte. + * The store also digests only the leading MAX_EVIDENCE_HASH_BYTES of a file, so + * evidence for a larger file reconciles stale for the same fail-closed reason. + */ +function toStoredFreshnessHash(hash: string): string { + return boundText( + hash.startsWith(FRESHNESS_HASH_PREFIX) + ? hash.slice(FRESHNESS_HASH_PREFIX.length) + : hash, + 256, + ) +} + +function findToolResultValueByKind( + output: unknown, + kind: 'read_files_result' | 'file_mutation_result', +): Record | undefined { + if (!Array.isArray(output)) return undefined + for (const part of output) { + if (!part || typeof part !== 'object' || Array.isArray(part)) continue + const record = part as Record + if (record.type !== 'json') continue + const value = record.value + if (!value || typeof value !== 'object' || Array.isArray(value)) continue + if ((value as Record).kind === kind) { + return value as Record + } + } + return undefined +} + +function asRecordArray(value: unknown): Record[] { + if (!Array.isArray(value)) return [] + return value.flatMap((item) => + item && typeof item === 'object' && !Array.isArray(item) + ? [item as Record] + : [], + ) +} + +/** + * True when a derived entry is byte-identical to what is already stored: same + * id, kind, path, summary and a defined, matching `freshnessHash`, with the + * path already present in the matching list. Only then can the commit be + * skipped without losing information. An entry without a hash (a delete, or an + * anchorless payload) can never be proven unchanged. + */ +function storedEvidenceIsUnchanged( + current: TaskMemoryV1, + storedById: Map, + item: TaskMemoryEvidenceV1, +): boolean { + const previous = storedById.get(item.id) + if (!previous || previous.stale === true) return false + if ( + previous.kind !== item.kind || + previous.path !== item.path || + previous.summary !== item.summary + ) { + return false + } + if (!item.freshnessHash || previous.freshnessHash !== item.freshnessHash) { + return false + } + if (!item.path) return false + const recorded = + item.kind === 'edit' ? current.editsMade : current.filesInspected + return recorded.includes(item.path) +} + +type DerivedToolEvidence = { + evidence: TaskMemoryEvidenceV1[] + filesInspected: string[] + editsMade: string[] +} + +/** + * Derives — but deliberately does not commit — the read/edit evidence a single + * tool result proves. No zod parse and no checksum happen here, so this stays + * cheap enough to run on every tool result while the expensive commit + * (`normalizeDraft` + `JSON.stringify` + `stableHash` over the whole memory) + * runs once per step. + */ +function deriveToolEvidence(params: { + toolName: string + callId: string + output: unknown + workspaceState?: WorkspaceStateV1 +}): DerivedToolEvidence { + const { toolName, callId, output, workspaceState } = params + const verifiedAt = Date.now() + const source = boundText(`${toolName}:${callId}`, 1_000) + const evidence: TaskMemoryEvidenceV1[] = [] + const filesInspected: string[] = [] + const editsMade: string[] = [] + + // Ids are deliberately stable per path (`read:` / `edit:`): + // `normalizeEvidence` dedupes by id keeping the newest `verifiedAt`, so + // re-reading or re-editing a file replaces its stale entry instead of + // accumulating one duplicate per tool call. The two prefixes are also the two + // hash namespaces documented on `toStoredFreshnessHash`: a read anchor hash is + // LF-normalized while a mutation `afterHash` is byte-exact, so the same file + // yields two different digests that must never be compared as one. + const readResult = + toolName === 'read_files' + ? findToolResultValueByKind(output, 'read_files_result') + : undefined + // Per-loop counter, so a 32-result read payload cannot consume the mutation + // loop's budget in the same output. + let readCount = 0 + for (const item of asRecordArray(readResult?.results)) { + if (readCount >= TOOL_EVIDENCE_PER_RESULT_CAP) break + // Only a COMPLETE WHOLE-FILE `ok` result carries a hash the next session can + // re-verify. `reconcileTaskMemoryEvidence` re-hashes the whole file, so a + // range/symbol slice anchor (the digest of that slice alone) could never + // match it, and partial/error/anchorless items carry no trustworthy digest + // at all — recording either would store permanently stale evidence. + if ( + item.status !== 'ok' || + item.selector !== 'file' || + item.complete !== true || + typeof item.path !== 'string' || + !item.path + ) { + continue + } + const anchor = + item.editAnchor && typeof item.editAnchor === 'object' + ? (item.editAnchor as Record) + : undefined + if (!anchor || typeof anchor.contentHash !== 'string') continue + const path = boundText(item.path, 1_000) + const startLine = + typeof anchor.startLine === 'number' ? anchor.startLine : '?' + const endLine = typeof anchor.endLine === 'number' ? anchor.endLine : '?' + evidence.push({ + id: boundText(`read:${path}`, 160), + kind: 'read', + summary: boundText(`Read ${path} lines ${startLine}-${endLine}`, 2_000), + source, + path, + freshnessHash: toStoredFreshnessHash(anchor.contentHash), + verifiedAt, + workspaceRevision: workspaceState?.revision, + }) + filesInspected.push(path) + readCount += 1 + } + + // Guard on the payload shape, not the tool name: every mutating tool + // (edit_transaction, str_replace, write_file, replace_range, create_plan) + // returns this kind, and an override returning something else is skipped. + const mutation = findToolResultValueByKind(output, 'file_mutation_result') + const mutationApplied = mutation?.outcome === 'applied' + let editCount = 0 + for (const action of asRecordArray(mutation?.actions)) { + if (editCount >= TOOL_EVIDENCE_PER_RESULT_CAP) break + // Trust a per-action outcome when the payload carries one; without it only + // a fully applied mutation confirms the action landed. + const applied = + typeof action.outcome === 'string' + ? action.outcome === 'applied' + : mutationApplied + if (!applied || typeof action.path !== 'string' || !action.path) continue + const path = boundText(action.path, 1_000) + const actionKind = + typeof action.action === 'string' ? boundText(action.action, 32) : 'edit' + evidence.push({ + id: boundText(`edit:${path}`, 160), + kind: 'edit', + summary: boundText(`${actionKind} ${path}`, 2_000), + source, + path, + // A deleted file has no post-state to hash, so record the edit without a + // freshnessHash rather than storing one that can never re-verify. + freshnessHash: + actionKind === 'delete' || typeof action.afterHash !== 'string' + ? undefined + : toStoredFreshnessHash(action.afterHash), + verifiedAt, + workspaceRevision: workspaceState?.revision, + }) + editsMade.push(path) + editCount += 1 + } + + return { evidence, filesInspected, editsMade } +} + +/** + * Commits one batch of derived evidence as a single task-memory revision. + * + * Returns `undefined` when the batch is empty (any other tool, or a custom/MCP + * tool with an unrelated payload) so the caller can skip the write entirely, and + * returns `current` unchanged when every derived entry is byte-identical to what + * is already stored so repeat reads of an unchanged file cost no revision. + */ +function commitDerivedToolEvidence(params: { + current?: TaskMemoryV1 + derived: DerivedToolEvidence + workspaceState?: WorkspaceStateV1 +}): TaskMemoryV1 | undefined { + const { current, derived, workspaceState } = params + const { evidence } = derived + if (evidence.length === 0) return undefined + + // Identity return, mirroring `ensureTaskMemoryGoal`: re-reading an unchanged + // file derives the same evidence, and committing it would re-normalize and + // re-checksum the whole memory (hundreds of KB at the evidence cap) to store + // nothing new. Any workspace movement still commits so the memory's own + // revision and snapshot id stay current. + if (current) { + const storedById = new Map( + current.evidence.map((item) => [item.id, item] as const), + ) + const workspaceUnchanged = + workspaceState === undefined || + (current.workspaceRevision === workspaceState.revision && + current.workspaceSnapshotId === + boundText(workspaceState.snapshotId, 256)) + if ( + workspaceUnchanged && + evidence.every((item) => + storedEvidenceIsUnchanged(current, storedById, item), + ) + ) { + return current + } + } + + const incoming = taskMemoryDraftV1Schema.parse({ + schemaVersion: 1, + goal: current?.goal ?? '', + // A whole step can derive more than one list cap's worth of paths, so the + // incoming draft is bounded here (newest wins) rather than letting the + // schema reject the batch and lose every entry in it. + filesInspected: uniqueRecent(derived.filesInspected, 128), + editsMade: uniqueRecent(derived.editsMade, 128), + historicalSummary: current?.historicalSummary ?? '', + evidence: normalizeEvidence(evidence), + ...(workspaceState + ? { + workspaceRevision: workspaceState.revision, + workspaceSnapshotId: boundText(workspaceState.snapshotId, 256), + } + : {}), + }) + return commitTaskMemory({ + current, + draft: mergeTaskMemoryDraft(current, incoming), + expectedRevision: current?.revision ?? -1, + }) +} + +/** + * Records the reads and edits an agent performed itself as task-memory evidence, + * committing one tool result immediately. Only child agents report through + * `mergeAgentReceiptIntoTaskMemory`, so without this the root agent's own + * exploration and edits are never remembered, and the stored `freshnessHash` + * values use the store's canonical form (see {@link toStoredFreshnessHash}) so + * they reconcile fresh — and therefore stay useful — in the next session. + * + * For callers that observe several results per step concurrently, prefer + * {@link bufferToolEvidenceForStep} plus + * {@link flushBufferedToolEvidenceIntoTaskMemory}: one commit per step keeps the + * whole-memory normalize/checksum off the per-result path and leaves exactly one + * writer per step. + */ +export function recordToolEvidenceInTaskMemory(params: { + current?: TaskMemoryV1 + toolName: string + callId: string + output: unknown + workspaceState?: WorkspaceStateV1 +}): TaskMemoryV1 | undefined { + const { current, toolName, callId, output, workspaceState } = params + return commitDerivedToolEvidence({ + current, + derived: deriveToolEvidence({ toolName, callId, output, workspaceState }), + workspaceState, + }) +} + +/** + * Derived evidence awaiting this step's single commit, keyed by the agent state + * object that owns the calls. A WeakMap so an abandoned step's buffer is + * collected with its agent state instead of leaking. + */ +const BUFFERED_STEP_EVIDENCE = new WeakMap() + +/** + * Derives the evidence one tool result proves and buffers it for this step's + * single commit. Only the cheap derivation runs per result; nothing is + * normalized, checksummed, or assigned to the agent state here, so concurrent + * tool calls in one step cannot each derive revision N+1 and clobber one + * another. + */ +export function bufferToolEvidenceForStep(params: { + owner: object + toolName: string + callId: string + output: unknown + workspaceState?: WorkspaceStateV1 +}): void { + const { owner, toolName, callId, output, workspaceState } = params + const derived = deriveToolEvidence({ + toolName, + callId, + output, + workspaceState, + }) + if (derived.evidence.length === 0) return + const buffered = BUFFERED_STEP_EVIDENCE.get(owner) + if (!buffered) { + BUFFERED_STEP_EVIDENCE.set(owner, derived) + return + } + if (buffered.evidence.length >= MAX_BUFFERED_STEP_EVIDENCE) return + buffered.evidence.push(...derived.evidence) + buffered.filesInspected.push(...derived.filesInspected) + buffered.editsMade.push(...derived.editsMade) +} + +/** + * Commits everything {@link bufferToolEvidenceForStep} buffered for this step as + * ONE revision and clears the buffer. The caller is the only writer at this + * point, so the returned memory (when it differs from `current`) can be assigned + * without a retry loop; `undefined` means nothing was buffered. + */ +export function flushBufferedToolEvidenceIntoTaskMemory(params: { + owner: object + current?: TaskMemoryV1 + workspaceState?: WorkspaceStateV1 +}): TaskMemoryV1 | undefined { + const buffered = BUFFERED_STEP_EVIDENCE.get(params.owner) + if (!buffered) return undefined + const result = commitDerivedToolEvidence({ + current: params.current, + derived: buffered, + workspaceState: params.workspaceState, + }) + BUFFERED_STEP_EVIDENCE.delete(params.owner) + return result +} + function extractSection(block: string, header: string, nextHeaders: string[]) { const escaped = header.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const lookahead = nextHeaders @@ -513,20 +988,103 @@ function evidenceIsFresh( workspaceRevision: number | undefined, ): boolean { if (item.stale) return false + // A path means `reconcileTaskMemoryEvidence` (task-memory-store.ts, run at + // session start) re-hashed this entry against disk and already wrote the + // authoritative verdict into `stale`. Do not re-add a revision check here: + // workspaceRevision bumps on every unrelated mutation anywhere in the repo, + // so it would discard hash-verified evidence. + // + // In-session contract: nothing re-reconciles mid-session, so an entry recorded + // earlier in THIS session stays trusted even after the same session edits that + // file. Entries carry no file content (only a summary and a digest), so the + // worst case is a stale pointer rather than stale content — the compiled + // banner in `compileTaskMemoryContext` states exactly that scope instead of + // promising session-wide freshness. + if (item.path) return true + // Pathless observations cannot be hash-verified, so the revision counter + // stays their only guard. if (workspaceRevision === undefined || item.workspaceRevision === undefined) { return true } - if (!['read', 'edit', 'validation', 'review'].includes(item.kind)) { + if (!REVISION_GUARDED_EVIDENCE_KINDS.has(item.kind)) { return true } return item.workspaceRevision === workspaceRevision } +/** + * True when `summary` mentions `focus` as a whole path rather than as the prefix + * of a longer one, so focus `src/a.ts` is not scored by a summary that only + * talks about `src/a.tsx`. + */ +function summaryMentionsFocusPath(summary: string, focus: string): boolean { + for ( + let index = summary.indexOf(focus); + index !== -1; + index = summary.indexOf(focus, index + 1) + ) { + const next = summary[index + focus.length] + if (next === undefined || !/[A-Za-z0-9_./\\-]/.test(next)) return true + } + return false +} + +function evidenceRelevanceScore( + item: TaskMemoryEvidenceV1, + focusPaths: string[], +): number { + let score = 0 + for (const focus of focusPaths) { + if (!focus) continue + if (item.path === focus) return 2 + // Exact equality above plus a SEGMENT-AWARE prefix here: a directory focus + // (`src/a`) still scores the files under it, while a near-miss filename + // (`src/a.tsx` for focus `src/a.ts`) scores nothing and cannot dilute the + // ranking. + if ( + item.path?.startsWith(`${focus}/`) || + summaryMentionsFocusPath(item.summary, focus) + ) { + score = 1 + } + } + return score +} + +/** + * Focus paths for `compileTaskMemoryContext`: the files the current request is + * actually working on, newest first, taken from the most recent non-stale + * read/edit evidence (`recordToolEvidenceInTaskMemory` writes one entry per + * path). Ranking against these keeps older validation, review, and decision + * evidence about those same files, which pure recency drops. + */ +export function deriveTaskMemoryFocusPaths( + memory: TaskMemoryV1 | undefined, + limit = 8, +): string[] { + if (!memory) return [] + const seen = new Set() + const paths: string[] = [] + // `normalizeEvidence` stores evidence ascending by verifiedAt, so walking + // backwards yields newest-first without another sort. + for (let index = memory.evidence.length - 1; index >= 0; index -= 1) { + const item = memory.evidence[index]! + if (item.stale || !item.path) continue + if (item.kind !== 'read' && item.kind !== 'edit') continue + if (seen.has(item.path)) continue + seen.add(item.path) + paths.push(item.path) + if (paths.length >= limit) break + } + return paths +} + function compileBoundedMemoryObject(params: { memory: TaskMemoryV1 agentType?: string | null contextWindowTokens?: number rootAgent?: boolean + focusPaths?: string[] maxChars: number }): Record { const { memory, maxChars, rootAgent } = params @@ -548,18 +1106,45 @@ function compileBoundedMemoryObject(params: { }) const evidenceLimit = Math.max(2, Math.floor((rootAgent ? 64 : 20) * scale)) - const evidence = memory.evidence - .filter((item) => evidenceIsFresh(item, memory.workspaceRevision)) - .slice(-evidenceLimit) - .map((item) => ({ - ...item, - summary: truncateMemoryText(item.summary, Math.max(160, 600 * scale)), - ...(item.source - ? { - source: truncateMemoryText(item.source, Math.max(100, 280 * scale)), - } - : {}), - })) + const fresh = memory.evidence.filter((item) => + evidenceIsFresh(item, memory.workspaceRevision), + ) + const focusPaths = params.focusPaths + // Recency alone drops evidence about the files this request is actually + // about, so rank by relevance when the caller names focus paths. Ties break + // on verifiedAt then original index, so equal-timestamp receipt bursts stay + // deterministic; emission order is still oldest -> newest either way. + const selectedEvidence = + focusPaths && focusPaths.length > 0 + ? fresh + .map((item, index) => ({ + item, + index, + score: evidenceRelevanceScore(item, focusPaths), + })) + .sort( + (a, b) => + b.score - a.score || + (b.item.verifiedAt ?? 0) - (a.item.verifiedAt ?? 0) || + b.index - a.index, + ) + .slice(0, evidenceLimit) + .sort( + (a, b) => + (a.item.verifiedAt ?? 0) - (b.item.verifiedAt ?? 0) || + a.index - b.index, + ) + .map((entry) => entry.item) + : fresh.slice(-evidenceLimit) + const evidence = selectedEvidence.map((item) => ({ + ...item, + summary: truncateMemoryText(item.summary, Math.max(160, 600 * scale)), + ...(item.source + ? { + source: truncateMemoryText(item.source, Math.max(100, 280 * scale)), + } + : {}), + })) return { schemaVersion: memory.schemaVersion, @@ -590,6 +1175,7 @@ export function compileTaskMemoryContext(params: { agentType?: string | null contextWindowTokens?: number rootAgent?: boolean + focusPaths?: string[] }): string { const fixedMax = params.rootAgent ? ROOT_CONTEXT_CHARS : CHILD_CONTEXT_CHARS const modelScaledMax = params.contextWindowTokens @@ -600,7 +1186,7 @@ export function compileTaskMemoryContext(params: { const serialized = JSON.stringify(compact, null, 2) return [ '', - 'Authoritative structured operational memory compiled for this request. Verify live files before mutation; stale evidence is excluded.', + 'Authoritative structured operational memory compiled for this request. Evidence that failed re-verification against disk at session start is excluded; entries recorded earlier in this same session are not re-verified, so verify live files before mutation.', serialized, '', ].join('\n') diff --git a/sdk/src/__tests__/change-file.test.ts b/sdk/src/__tests__/change-file.test.ts index 000ba2b538..5dfbc14f72 100644 --- a/sdk/src/__tests__/change-file.test.ts +++ b/sdk/src/__tests__/change-file.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test } from 'bun:test' +import { MAX_FILE_CHANGES_PER_TRANSACTION } from '@codebuff/common/actions' import { createMockFs } from '@codebuff/common/testing/mocks/filesystem' +import { fileMutationResultV1Schema } from '@codebuff/common/tools/results/filesystem' import { getContentHash } from '@codebuff/common/util/content-hash' import { changeFile, changeFiles } from '../tools/change-file' -import { MAX_FILE_CHANGES_PER_TRANSACTION } from '@codebuff/common/actions' const capabilityIssuer = { projectId: '/repo', @@ -224,6 +225,82 @@ describe('changeFile', () => { expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe('before\n') }) + test('reports update for a rejected unguarded write to an existing file', async () => { + const fs = createMockFs({ files: { '/repo/src/file.ts': 'before\n' } }) + fs.conditionalCommit = async () => ({ + applied: false, + actualHash: getContentHash('external\n'), + }) + + const result = await changeFile({ + // Absolute prompt path: the failure result must still report the + // project-relative path, like the applied branch does. + parameters: { + type: 'file', + path: '/repo/src/file.ts', + content: 'after\n', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]?.type === 'json' ? result[0].value : null).toMatchObject({ + outcome: 'not_applied', + actions: [ + expect.objectContaining({ + action: 'update', + path: 'src/file.ts', + outcome: 'not_applied', + }), + ], + errors: [expect.objectContaining({ code: 'stale_state' })], + }) + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe('before\n') + }) + + test('logs a redacted diagnostic when a guarded update is stale', async () => { + const fs = createMockFs({ files: { '/repo/src/file.ts': 'current\n' } }) + const logged: Array<{ data: unknown; message?: string }> = [] + const logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: (data: unknown, message?: string) => { + logged.push({ data, message }) + }, + } + + const result = await changeFile({ + parameters: { + type: 'file', + path: 'src/file.ts', + content: 'sensitive-new-content\n', + expectedHash: getContentHash('stale\n'), + }, + cwd: '/repo', + fs, + capabilityIssuer, + logger, + }) + + expect(result[0]?.type === 'json' ? result[0].value : null).toMatchObject({ + outcome: 'not_applied', + errors: [expect.objectContaining({ code: 'stale_state' })], + }) + expect(logged).toHaveLength(1) + expect(logged[0].data).toEqual({ + path: 'src/file.ts', + type: 'file', + byteLength: Buffer.byteLength('sensitive-new-content\n'), + code: 'stale_state', + }) + const serialized = JSON.stringify(logged[0]) + expect(serialized).not.toContain('sensitive-new-content') + expect(serialized).not.toContain('current') + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe('current\n') + }) + test('fails closed for a guarded update when conditional commit is unavailable', async () => { const fs = createMockFs({ files: { '/repo/src/file.ts': 'before\n' } }) fs.conditionalCommit = undefined @@ -339,27 +416,21 @@ describe('changeFile', () => { capabilityIssuer, }) - const output = result[0] - expect(output.type).toBe('json') - if ( - output.type === 'json' && - output.value !== null && - typeof output.value === 'object' && - 'kind' in output.value && - output.value.kind === 'file_mutation_result' - ) { - expect(output.value).toMatchObject({ - kind: 'file_mutation_result', - outcome: 'not_applied', - }) - expect( - output.value.actions.every( - (action) => - action.afterContent === undefined && - action.editAnchor === undefined, - ), - ).toBe(true) - } + // Parsed unconditionally: a shape regression must fail the test instead of + // silently skipping these assertions. + const mutation = fileMutationResultV1Schema.parse( + result[0]?.type === 'json' ? result[0].value : null, + ) + expect(mutation).toMatchObject({ + kind: 'file_mutation_result', + outcome: 'not_applied', + }) + expect( + mutation.actions.every( + (action) => + action.afterContent === undefined && action.editAnchor === undefined, + ), + ).toBe(true) expect(await fs.readFile('/repo/src/one.ts', 'utf-8')).toBe( 'const one = 1\n', ) @@ -408,14 +479,10 @@ describe('changeFile', () => { fs, }) - const output = result[0] - expect(output.type).toBe('json') - if (output.type === 'json') { - expect(output.value).toMatchObject({ - kind: 'file_mutation_result', - outcome: 'rolled_back', - }) - } + expect(result[0]?.type === 'json' ? result[0].value : null).toMatchObject({ + kind: 'file_mutation_result', + outcome: 'rolled_back', + }) expect(files['/repo/src/one.ts']).toBe('const one = 1\n') expect(files['/repo/src/two.ts']).toBe('const two = 1\n') }) @@ -563,7 +630,10 @@ describe('changeFile', () => { capabilityIssuer, }) - expect(result[0]?.type === 'json' ? result[0].value : null).toMatchObject({ + const mutation = fileMutationResultV1Schema.parse( + result[0]?.type === 'json' ? result[0].value : null, + ) + expect(mutation).toMatchObject({ kind: 'file_mutation_result', outcome: 'applied', actions: [ @@ -572,10 +642,9 @@ describe('changeFile', () => { path: 'created.txt', afterContent: 'created', }), - expect.not.objectContaining({ + expect.objectContaining({ action: 'delete', path: 'delete.txt', - afterContent: expect.anything(), }), expect.objectContaining({ action: 'move', @@ -590,6 +659,11 @@ describe('changeFile', () => { }), ], }) + // A deleted path has no post-state, so it must carry neither content nor + // a read capability that would authorize editing the removed file. + const deleteAction = mutation.actions[1] + expect(deleteAction.afterContent).toBeUndefined() + expect(deleteAction.editAnchor).toBeUndefined() expect(await fs.readFile('/repo/created.txt', 'utf-8')).toBe('created') await expect(fs.readFile('/repo/delete.txt', 'utf-8')).rejects.toThrow() await expect(fs.readFile('/repo/source.txt', 'utf-8')).rejects.toThrow() @@ -659,6 +733,15 @@ describe('changeFile', () => { ).toMatchObject({ kind: 'file_mutation_result', outcome: 'not_applied', + // A rejected write to a missing path is still reported as a `create`, + // not an `update`, so the agent can tell why it was blocked. + actions: [ + expect.objectContaining({ + action: 'create', + path: '.env', + outcome: 'not_applied', + }), + ], errors: [expect.objectContaining({ code: 'blocked' })], }) const customBlocked = await changeFile({ diff --git a/sdk/src/__tests__/initial-session-state.test.ts b/sdk/src/__tests__/initial-session-state.test.ts index 6b94483d8c..c9cd86404b 100644 --- a/sdk/src/__tests__/initial-session-state.test.ts +++ b/sdk/src/__tests__/initial-session-state.test.ts @@ -163,6 +163,48 @@ describe('Initial Session State', () => { expect(readFilePaths.some((p) => p.endsWith('knowledge.md'))).toBe(true) }) + test('skips discovered files when stat omits size', async () => { + // The size cap fails closed: an adapter whose stat carries no `size` must + // not have every discovered file read fully into memory just because the + // cap cannot be evaluated. + mockFs.readdir = (async (dirPath: string) => { + if (dirPath === '/test-project') { + return ['src', '.git', 'knowledge.md', 'README.md', '.gitignore'] + } + if (dirPath === '/test-project/src') { + return ['index.ts', 'utils.ts'] + } + return [] + }) as CodebuffFileSystem['readdir'] + mockFs.stat = (async (filePath: string) => + ({ + isDirectory: () => + filePath === '/test-project/src' || filePath === '/test-project/.git', + isFile: () => + filePath !== '/test-project/src' && filePath !== '/test-project/.git', + }) as MockStatResult) as CodebuffFileSystem['stat'] + + const readFilePaths: string[] = [] + const originalReadFile = mockFs.readFile + mockFs.readFile = (async (filePath: string, encoding?: BufferEncoding) => { + readFilePaths.push(filePath) + return originalReadFile(filePath, encoding) + }) as CodebuffFileSystem['readFile'] + + await initialSessionState({ + cwd: '/test-project', + projectFiles: undefined, + fs: mockFs, + logger: mockLogger, + }) + + expect(readFilePaths.some((p) => p.endsWith('src/index.ts'))).toBe(false) + expect(readFilePaths.some((p) => p.endsWith('src/utils.ts'))).toBe(false) + // Knowledge files are loaded directly rather than through the size-capped + // discovered-project reader, so they must still be read. + expect(readFilePaths.some((p) => p.endsWith('knowledge.md'))).toBe(true) + }) + test('derives knowledgeFiles from projectFiles when not provided', async () => { const projectFiles = { 'src/index.ts': 'console.log("Hello world");', diff --git a/sdk/src/__tests__/replace-range.test.ts b/sdk/src/__tests__/replace-range.test.ts index 87dba34a1c..62f2c5ab39 100644 --- a/sdk/src/__tests__/replace-range.test.ts +++ b/sdk/src/__tests__/replace-range.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' +import { MAX_TRANSACTION_FILE_BYTES } from '@codebuff/common/actions' import { createMockFs } from '@codebuff/common/testing/mocks/filesystem' import { encodeReadCapabilityToken, @@ -185,6 +186,233 @@ describe('replaceRange', () => { ) }) + test('rejects a capability range beyond a shortened file without mutating it', async () => { + const original = 'line 1\nline 2\n' + const fs = createMockFs({ + files: { '/repo/src/file.ts': original }, + }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 1, + endLine: 5, + content: 'line 1\nline 2\nline 3\nline 4\nline 5', + }), + newContent: 'replacement', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ type: 'json' }) + const { errorMessage } = (result[0] as { value: { errorMessage: string } }) + .value + expect(errorMessage).toContain( + 'the capability-covered range 1-5 is beyond the current file length (2 lines)', + ) + // The guard itself accepts the phantom trailing entry, so the diagnostic + // names the highest line a capability may legally bind (3 here) instead of + // only the visible count. + expect(errorMessage).toContain('Capability bounds may extend to line 3') + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe(original) + }) + + test('reports zero lines for an empty file without mutating it', async () => { + const fs = createMockFs({ files: { '/repo/src/file.ts': '' } }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 1, + endLine: 2, + content: '', + }), + newContent: 'replacement', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + errorMessage: expect.stringContaining( + 'the capability-covered range 1-2 is beyond the current file length (0 lines)', + ), + }, + }) + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe('') + }) + + test('applies a single-line edit to an empty file', async () => { + // Pins the empty-file applied path: start === end === 0 splices in exactly + // the newContent with no added terminator. + const fs = createMockFs({ files: { '/repo/src/file.ts': '' } }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 1, + endLine: 1, + content: '', + }), + newContent: 'first line', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { kind: 'file_mutation_result', outcome: 'applied' }, + }) + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe('first line') + }) + + test('applies a multi-line edit to an empty file with the LF fallback', async () => { + const fs = createMockFs({ files: { '/repo/src/file.ts': '' } }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 1, + endLine: 1, + content: '', + }), + newContent: 'first line\nsecond line', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { kind: 'file_mutation_result', outcome: 'applied' }, + }) + // The single empty line spans bytes 0-0, and no terminator exists anywhere + // in the file, so the inserted terminator comes from the LF fallback. + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe( + 'first line\nsecond line', + ) + }) + + test('does not echo an unbounded raw path for invalid parameters', async () => { + const original = 'line 1\nline 2\n' + const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + // Longer than the bound on the echoed path, so the error message cannot be + // amplified by unparsed model input. + const longPath = `src/${'a'.repeat(600)}.ts` + + const result = await replaceRange({ + parameters: { + path: longPath, + readCapability: capability({ + path: longPath, + startLine: 1, + endLine: 2, + content: 'line 1\nline 2', + }), + // startLine after endLine, so the input never parses. + startLine: 2, + endLine: 1, + newContent: 'replacement', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + // Reported as the shared sentinel rather than the empty path, so the + // agent can tell an unusable path from a missing one. + file: '(unparsed)', + errorMessage: 'Missing or invalid replace_range parameters.', + }, + }) + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe(original) + }) + + test('does not echo an unbounded parsed path for a target outside the project', async () => { + const original = 'line 1\nline 2\n' + const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + // Parses cleanly (the schema does not bound `path`) but resolves outside + // the project, so the echo must be bounded here too. + const outsidePath = `/outside/${'a'.repeat(600)}.ts` + + const result = await replaceRange({ + parameters: { + path: outsidePath, + readCapability: capability({ + path: outsidePath, + startLine: 1, + endLine: 2, + content: 'line 1\nline 2', + }), + newContent: 'replacement', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + file: '(unparsed)', + errorMessage: 'file path is outside the project directory', + }, + }) + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe(original) + }) + + test('surfaces a read failure code without mutating the file', async () => { + const original = 'line 1\nline 2\n' + const files: Record = { '/repo/src/file.ts': original } + const fs = createMockFs({ + files, + readFileImpl: async () => { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + }, + writeFileImpl: async (path, content) => { + files[path] = content + }, + }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 1, + endLine: 2, + content: 'line 1\nline 2', + }), + newContent: 'updated line 1\nupdated line 2', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + errorMessage: 'replace_range failed with EACCES: permission denied', + }, + }) + expect(files['/repo/src/file.ts']).toBe(original) + }) + test('rejects a capability from another run', async () => { const fs = createMockFs({ files: { '/repo/src/file.ts': 'line 1\nline 2\n' }, @@ -300,26 +528,20 @@ describe('replaceRange', () => { ) }) - test('applies resolved absolute lines for the second literal occurrence', async () => { - // The post-runtime shape: the agent-runtime handler already resolved - // occurrence targeting into absolute lines, so the applicator only sees - // startLine/endLine. Lines 4-4 are the SECOND "repeat();". - const original = - 'const a = 1\nrepeat();\nconst b = 2\nrepeat();\nconst c = 3\n' - const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + test('keeps original line endings outside the edited range in a mixed-ending file', async () => { + const fs = createMockFs({ + files: { '/repo/src/file.ts': 'a\r\nb\nc\n' }, + }) const result = await replaceRange({ parameters: { path: 'src/file.ts', readCapability: capability({ - startLine: 1, - endLine: 5, - content: - 'const a = 1\nrepeat();\nconst b = 2\nrepeat();\nconst c = 3', + startLine: 2, + endLine: 2, + content: 'b', }), - startLine: 4, - endLine: 4, - newContent: 'repeat(2);', + newContent: 'B', }, cwd: '/repo', fs, @@ -330,31 +552,24 @@ describe('replaceRange', () => { type: 'json', value: { kind: 'file_mutation_result', outcome: 'applied' }, }) - // Only the second occurrence changed; the first is untouched. - expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe( - 'const a = 1\nrepeat();\nconst b = 2\nrepeat(2);\nconst c = 3\n', - ) + // Only line 2 was authorized: line 1 keeps CRLF and line 3 keeps LF. + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe('a\r\nB\nc\n') }) - test('rejects unresolved occurrence targeting without mutating the file', async () => { - // occurrence alone (no startLine/endLine) is schema-valid, but the SDK - // applicator deliberately does not resolve occurrence: only the agent - // runtime does, against the content it just read. - const original = - 'const a = 1\nrepeat();\nconst b = 2\nrepeat();\nconst c = 3\n' - const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + test('keeps an LF-only edited range LF-only for multi-line newContent in a mixed-ending file', async () => { + const fs = createMockFs({ + files: { '/repo/src/file.ts': 'a\r\nb\nc\n' }, + }) const result = await replaceRange({ parameters: { path: 'src/file.ts', readCapability: capability({ - startLine: 1, - endLine: 5, - content: - 'const a = 1\nrepeat();\nconst b = 2\nrepeat();\nconst c = 3', + startLine: 2, + endLine: 2, + content: 'b', }), - occurrence: { match: 'repeat();', occurrence: 2 }, - newContent: 'repeat(2);', + newContent: 'B1\nB2', }, cwd: '/repo', fs, @@ -363,67 +578,56 @@ describe('replaceRange', () => { expect(result[0]).toMatchObject({ type: 'json', - value: { - errorMessage: expect.stringContaining( - 'must be resolved to absolute lines', - ), - }, + value: { kind: 'file_mutation_result', outcome: 'applied' }, }) - expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe(original) + // The replaced span ends with LF, so the inserted terminator is LF even + // though line 1 elsewhere in the file uses CRLF. + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe( + 'a\r\nB1\nB2\nc\n', + ) }) - test('rejects occurrence combined with startLine/endLine without mutating the file', async () => { - // Mutual exclusion is enforced by the schema refine, so the applicator - // never reaches its own occurrence guard. - const original = - 'const a = 1\nrepeat();\nconst b = 2\nrepeat();\nconst c = 3\n' - const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + test('uses CRLF for multi-line newContent when the replaced span is CRLF', async () => { + const fs = createMockFs({ + files: { '/repo/src/file.ts': 'a\nb\r\nc\n' }, + }) - const result = await replaceRange({ + await replaceRange({ parameters: { path: 'src/file.ts', readCapability: capability({ - startLine: 1, - endLine: 5, - content: - 'const a = 1\nrepeat();\nconst b = 2\nrepeat();\nconst c = 3', + startLine: 2, + endLine: 2, + content: 'b', }), - startLine: 4, - endLine: 4, - occurrence: { match: 'repeat();', occurrence: 2 }, - newContent: 'repeat(2);', + newContent: 'B1\nB2', }, cwd: '/repo', fs, capabilityIssuer, }) - expect(result[0]).toMatchObject({ - type: 'json', - value: { - errorMessage: 'Missing or invalid replace_range parameters.', - }, - }) - expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe(original) + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe( + 'a\nB1\r\nB2\r\nc\n', + ) }) - test('replaces a resolved multi-line block and preserves surrounding lines', async () => { - const original = - 'function a() {\n return 1\n}\nfunction b() {\n return 2\n}\n' - const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + test('falls back to the file-wide CRLF style for the final line of a file with no trailing newline', async () => { + const fs = createMockFs({ + files: { '/repo/src/file.ts': 'a\r\nb' }, + }) const result = await replaceRange({ parameters: { path: 'src/file.ts', readCapability: capability({ startLine: 1, - endLine: 6, - content: - 'function a() {\n return 1\n}\nfunction b() {\n return 2\n}', + endLine: 2, + content: 'a\nb', }), - startLine: 4, - endLine: 6, - newContent: 'function b() {\n return 20\n}', + startLine: 2, + endLine: 2, + newContent: 'B1\nB2', }, cwd: '/repo', fs, @@ -434,8 +638,670 @@ describe('replaceRange', () => { type: 'json', value: { kind: 'file_mutation_result', outcome: 'applied' }, }) + // The replaced span is the unterminated final line, and nothing follows + // it, so the terminator comes from the CRLF already observed while walking + // to endLine. The file keeps its missing trailing newline. expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe( - 'function a() {\n return 1\n}\nfunction b() {\n return 20\n}\n', + 'a\r\nB1\r\nB2', ) }) + + test('falls back to LF for an unterminated final line when the walk to endLine saw no CRLF', async () => { + const fs = createMockFs({ + files: { '/repo/src/file.ts': 'a\nb' }, + }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 1, + endLine: 2, + content: 'a\nb', + }), + startLine: 2, + endLine: 2, + newContent: 'B1\nB2', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { kind: 'file_mutation_result', outcome: 'applied' }, + }) + // Every terminator seen up to endLine is LF, so the bounded fallback stays + // LF and the missing trailing newline is preserved. + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe('a\nB1\nB2') + }) + + test('uses the lone LF that terminates the replaced span over the file-wide CRLF style', async () => { + const fs = createMockFs({ + files: { '/repo/src/file.ts': 'a\r\nb\nc\r\n' }, + }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 2, + endLine: 2, + content: 'b', + }), + newContent: 'B1\nB2', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { kind: 'file_mutation_result', outcome: 'applied' }, + }) + // The span itself contains no newline, but it is followed by a lone LF, so + // the edited range stays LF instead of being promoted to the file-wide + // CRLF style used by lines 1 and 3. + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe( + 'a\r\nB1\nB2\nc\r\n', + ) + }) + + test('treats a lone CR as line content, not a terminator', async () => { + // `normalizeLineEndings` collapses only \r\n, so this whole fixture is a + // SINGLE line as far as this tool's line numbering is concerned — a + // capability for line 2 would be rejected as beyond the file length. + const fs = createMockFs({ + files: { '/repo/src/file.ts': 'a\rb\rc' }, + }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 1, + endLine: 1, + content: 'a\rb\rc', + }), + newContent: 'B1\nB2', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { kind: 'file_mutation_result', outcome: 'applied' }, + }) + // The CRs were part of the replaced line's content, and the terminator + // inserted between the two newContent lines is LF. + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe('B1\nB2') + }) + + test('applies resolved absolute lines for the second literal occurrence', async () => { + // The post-runtime shape: the agent-runtime handler already resolved + // occurrence targeting into absolute lines, so the applicator only sees + // startLine/endLine. Lines 4-4 are the SECOND "repeat();". + const original = + 'const a = 1\nrepeat();\nconst b = 2\nrepeat();\nconst c = 3\n' + const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 1, + endLine: 5, + content: + 'const a = 1\nrepeat();\nconst b = 2\nrepeat();\nconst c = 3', + }), + startLine: 4, + endLine: 4, + newContent: 'repeat(2);', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { kind: 'file_mutation_result', outcome: 'applied' }, + }) + // Only the second occurrence changed; the first is untouched. + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe( + 'const a = 1\nrepeat();\nconst b = 2\nrepeat(2);\nconst c = 3\n', + ) + }) + + test('rejects unresolved occurrence targeting before reading the file', async () => { + // occurrence alone (no startLine/endLine) is schema-valid, but the SDK + // applicator deliberately does not resolve occurrence: only the agent + // runtime does, against the content it just read. Reading throws here, so + // the guard is proven to run before the file read rather than only + // asserting the message text. + const original = + 'const a = 1\nrepeat();\nconst b = 2\nrepeat();\nconst c = 3\n' + const files: Record = { '/repo/src/file.ts': original } + const fs = createMockFs({ + files, + readFileImpl: async () => { + throw new Error('replace_range read the file before the guard') + }, + writeFileImpl: async (path, content) => { + files[path] = content + }, + }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 1, + endLine: 5, + content: + 'const a = 1\nrepeat();\nconst b = 2\nrepeat();\nconst c = 3', + }), + occurrence: { match: 'repeat();', occurrence: 2 }, + newContent: 'repeat(2);', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + errorMessage: expect.stringContaining( + 'must be resolved to absolute lines', + ), + }, + }) + expect(files['/repo/src/file.ts']).toBe(original) + }) + + test('rejects occurrence combined with startLine/endLine without mutating the file', async () => { + // Mutual exclusion is enforced by the schema refine, so the applicator + // never reaches its own occurrence guard. + const original = + 'const a = 1\nrepeat();\nconst b = 2\nrepeat();\nconst c = 3\n' + const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 1, + endLine: 5, + content: + 'const a = 1\nrepeat();\nconst b = 2\nrepeat();\nconst c = 3', + }), + startLine: 4, + endLine: 4, + occurrence: { match: 'repeat();', occurrence: 2 }, + newContent: 'repeat(2);', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + // The raw path is echoed back so the agent can tell which call failed. + file: 'src/file.ts', + errorMessage: 'Missing or invalid replace_range parameters.', + }, + }) + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe(original) + }) + + test('reports the unreportable-path sentinel for invalid parameters with a non-string path', async () => { + const original = 'line 1\nline 2\n' + const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + + const result = await replaceRange({ + parameters: { + path: 42, + readCapability: capability({ + startLine: 1, + endLine: 2, + content: 'line 1\nline 2', + }), + newContent: 'replacement', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + file: '(unparsed)', + errorMessage: 'Missing or invalid replace_range parameters.', + }, + }) + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe(original) + }) + + test('reports an empty file for invalid parameters with no path supplied', async () => { + // The counterpart to the sentinel cases above: a missing `path` key is the + // one input that still echoes the empty path. + const original = 'line 1\nline 2\n' + const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + + const result = await replaceRange({ + parameters: { + readCapability: capability({ + startLine: 1, + endLine: 2, + content: 'line 1\nline 2', + }), + newContent: 'replacement', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + file: '', + errorMessage: 'Missing or invalid replace_range parameters.', + }, + }) + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe(original) + }) + + test('reports an empty file for parameters that are not an object', async () => { + // The echo reads `path` straight off the raw parameters, so a payload that + // is not an object at all must still resolve to the empty path instead of + // throwing on the property access. + const original = 'line 1\nline 2\n' + const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + + for (const parameters of [null, undefined, 'src/file.ts', 42]) { + const result = await replaceRange({ + parameters, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + file: '', + errorMessage: 'Missing or invalid replace_range parameters.', + }, + }) + } + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe(original) + }) + + test('replaces a resolved multi-line block and preserves surrounding lines', async () => { + const original = + 'function a() {\n return 1\n}\nfunction b() {\n return 2\n}\n' + const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 1, + endLine: 6, + content: + 'function a() {\n return 1\n}\nfunction b() {\n return 2\n}', + }), + startLine: 4, + endLine: 6, + newContent: 'function b() {\n return 20\n}', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { kind: 'file_mutation_result', outcome: 'applied' }, + }) + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe( + 'function a() {\n return 1\n}\nfunction b() {\n return 20\n}\n', + ) + }) + + test('defaults an omitted target to the whole capability-covered range', async () => { + // Both `??` defaults at the applicator's single call site: startLine and + // endLine are omitted, so the complete multi-line observed range is + // replaced and the lines outside it are untouched. + const fs = createMockFs({ + files: { '/repo/src/file.ts': 'a\nb\nc\nd\n' }, + }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 2, + endLine: 3, + content: 'b\nc', + }), + newContent: 'B\nC\nextra', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { kind: 'file_mutation_result', outcome: 'applied' }, + }) + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe( + 'a\nB\nC\nextra\nd\n', + ) + }) + + test('rejects a capability issued for another path with the single scope message', async () => { + const original = 'line 1\nline 2\n' + const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + path: 'src/other.ts', + startLine: 1, + endLine: 1, + content: 'line 1', + }), + newContent: 'updated line 1', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + file: 'src/file.ts', + errorMessage: expect.stringContaining( + 'different project, path, or agent run', + ), + }, + }) + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe(original) + }) + + test('rejects an undecodable readCapability at the schema boundary', async () => { + // The schema's superRefine rejects any token that fails to decode, so the + // applicator's scope guard only ever sees a decodable cap.v3 token: a + // decode failure surfaces as an invalid-parameters result, never as a + // decode message from the guard. + const original = 'line 1\nline 2\n' + const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: 'cap.v3.not-a-real-token', + newContent: 'updated line 1', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + file: 'src/file.ts', + errorMessage: 'Missing or invalid replace_range parameters.', + }, + }) + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe(original) + }) + + test('collapses a newline-bearing raw path into the single-line sentinel', async () => { + const original = 'line 1\nline 2\n' + const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + // A short enough path to pass the length bound, but it carries a newline + // that would otherwise forge an extra line in the agent-facing diagnostic. + const injectedPath = 'src/a\nfake: line.ts' + + const result = await replaceRange({ + parameters: { + path: injectedPath, + readCapability: capability({ + path: injectedPath, + startLine: 1, + endLine: 2, + content: 'line 1\nline 2', + }), + // startLine after endLine, so the input never parses. + startLine: 2, + endLine: 1, + newContent: 'replacement', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ type: 'json' }) + const { file, errorMessage } = ( + result[0] as { value: { file: string; errorMessage: string } } + ).value + expect(file).toBe('(unparsed)') + expect(file).not.toContain('\n') + expect(errorMessage).toBe('Missing or invalid replace_range parameters.') + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe(original) + }) + + test('surfaces a read failure without a code unprefixed', async () => { + const original = 'line 1\nline 2\n' + const files: Record = { '/repo/src/file.ts': original } + const fs = createMockFs({ + files, + readFileImpl: async () => { + throw new Error('boom') + }, + writeFileImpl: async (path, content) => { + files[path] = content + }, + }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 1, + endLine: 2, + content: 'line 1\nline 2', + }), + newContent: 'updated line 1\nupdated line 2', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + // No `code` on the thrown error, so the message is reported as-is with no + // `replace_range failed with ...` prefix. + expect(result[0]).toMatchObject({ + type: 'json', + value: { file: 'src/file.ts', errorMessage: 'boom' }, + }) + expect(files['/repo/src/file.ts']).toBe(original) + }) + + test('rejects a line-endings-only edit as a no-op without rewriting the file', async () => { + const original = 'a\r\n' + const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 1, + endLine: 2, + content: 'a\n', + }), + newContent: 'a\r\n', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + // The capability covers the whole file, including the phantom trailing + // entry a read reports past the final terminator, so the target range text + // is 'a\n'. Both sides are compared LF-normalized, so re-terminating that + // line is a no-op here rather than an LF->CRLF rewrite. + expect(result[0]).toMatchObject({ + type: 'json', + value: { + file: 'src/file.ts', + errorMessage: expect.stringContaining('identical to the current range'), + }, + }) + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe(original) + }) + + test('refuses to commit when only the line terminators changed after the read', async () => { + // The splice is computed from the raw bytes of the pre-read, while + // `expectedHash` is LF-normalized: a concurrent CRLF->LF-only external + // rewrite passes that guard, so the byte-exact expectation must stop the + // commit instead of resurrecting the CRLF terminators file-wide. + const crlf = 'line 1\r\nline 2\r\n' + const lf = 'line 1\nline 2\n' + const files: Record = { '/repo/src/file.ts': crlf } + const fs = createMockFs({ + files, + readFileImpl: async (path) => { + const current = files[path]! + // The external CRLF->LF-only rewrite lands right after + // replace_range's own pre-check read, so every read taken inside + // changeFile's lock observes the LF-only file. + if (current === crlf) files[path] = lf + return current + }, + writeFileImpl: async (path, content) => { + files[path] = content + }, + }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 2, + endLine: 2, + content: 'line 2', + }), + newContent: 'updated line 2', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + kind: 'file_mutation_result', + outcome: 'not_applied', + errors: [ + expect.objectContaining({ + code: 'stale_state', + message: expect.stringContaining('exact bytes changed'), + }), + ], + }, + }) + expect(files['/repo/src/file.ts']).toBe(lf) + }) + + test('rejects an already-oversize target before normalizing or splitting it', async () => { + const original = 'x'.repeat(MAX_TRANSACTION_FILE_BYTES + 1) + const files: Record = { '/repo/src/file.ts': original } + const fs = createMockFs({ + files, + writeFileImpl: async (path, content) => { + files[path] = content + }, + }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 1, + endLine: 1, + content: 'line 1', + }), + newContent: 'replacement', + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + // The early guard runs before the capability-freshness check, so an + // oversize file is refused even though the capability content no longer + // matches it. + expect(result[0]).toMatchObject({ + type: 'json', + value: { + file: 'src/file.ts', + errorMessage: expect.stringContaining( + `is already ${MAX_TRANSACTION_FILE_BYTES + 1} bytes, over the ${MAX_TRANSACTION_FILE_BYTES}-byte per-file limit`, + ), + }, + }) + expect(files['/repo/src/file.ts']).toBe(original) + }) + + test('rejects an oversize result with the declared error shape and leaves the file unchanged', async () => { + // `newContent` is unbounded in the schema, so without the applicator's own + // byte check `changeFile`'s per-file refine would throw a ZodError and the + // caller would lose the `file` key this tool declares. + const original = 'line 1\nline 2\n' + const fs = createMockFs({ files: { '/repo/src/file.ts': original } }) + + const result = await replaceRange({ + parameters: { + path: 'src/file.ts', + readCapability: capability({ + startLine: 1, + endLine: 1, + content: 'line 1', + }), + newContent: 'x'.repeat(MAX_TRANSACTION_FILE_BYTES + 1), + }, + cwd: '/repo', + fs, + capabilityIssuer, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + file: 'src/file.ts', + errorMessage: expect.stringContaining( + `over the ${MAX_TRANSACTION_FILE_BYTES}-byte per-file limit`, + ), + }, + }) + expect(await fs.readFile('/repo/src/file.ts', 'utf-8')).toBe(original) + }) }) diff --git a/sdk/src/__tests__/run-mutation-dispatch.test.ts b/sdk/src/__tests__/run-mutation-dispatch.test.ts new file mode 100644 index 0000000000..6490c5b91d --- /dev/null +++ b/sdk/src/__tests__/run-mutation-dispatch.test.ts @@ -0,0 +1,594 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import * as mainPromptModule from '@codebuff/agent-runtime/main-prompt' +import { createMockFs } from '@codebuff/common/testing/mocks/filesystem' +import { getInitialSessionState } from '@codebuff/common/types/session-state' +import { + encodeReadCapabilityToken, + getContentHash, + getExactContentHash, +} from '@codebuff/common/util/content-hash' +import { getStubProjectFileContext } from '@codebuff/common/util/file' +import { + afterAll, + afterEach, + describe, + expect, + it, + mock, + spyOn, +} from 'bun:test' + +import { OpenbuffClient } from '../client' +import * as databaseModule from '../impl/database' +import { handleToolCall } from '../run' +import { HarnessApprovalService } from '../services/harness-enforcement' +import { LocalHarnessStore } from '../services/local-harness-store' + +import type { FilesystemMutationEvent, OpenbuffClientOptions } from '../run' +import type { CommitReceiptV1 } from '@codebuff/common/tools/results/filesystem' +import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem' +import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part' +import type { WorkspaceStateV1 } from '@codebuff/common/types/workspace-state' + +// Harness/journal state is written through real node fs, so keep it inside a +// temp root instead of the user's config directory. +const harnessStateDir = mkdtempSync( + path.join(tmpdir(), 'openbuff-run-mutation-'), +) + +const auditInput = { + sessionSlug: 'audit-dispatch', + shardId: 'runtime-1', + findings: [], + coverage: { + subsystemIds: ['sdk'], + featureIds: ['tool-dispatch'], + files: ['sdk/src/run.ts'], + }, + noIssuesFound: true, +} +// Snapshot-bound variant, so at least one dispatch case drives the +// structuralReceipt branch through the real handleToolCall wiring. +const snapshotBoundAuditInput = { + ...auditInput, + snapshotId: 'snapshot-dispatch-1', + coverage: { ...auditInput.coverage, domains: ['security'] }, +} +const auditArtifactPath = + '.agents/sessions/audit-dispatch/findings/runtime-1.md' + +function mockDatabase() { + spyOn(databaseModule, 'getUserInfoFromApiKey').mockResolvedValue({ + id: 'user-123', + email: 'test@example.com', + discord_id: null, + stripe_customer_id: null, + banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), + }) + spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) + spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') + spyOn(databaseModule, 'finishAgentRun').mockResolvedValue(undefined) + spyOn(databaseModule, 'addAgentStep').mockResolvedValue('step-1') +} + +/** + * Stubs the agent loop and dispatches exactly the given client tool calls + * through the real `handleToolCall` wiring, returning each tool output. + */ +async function dispatchToolCalls(params: { + calls: Array<{ toolName: string; input: Record }> + clientOptions: OpenbuffClientOptions + /** + * Optional sink for the canonical commit receipt each dispatch returns + * alongside its output, so a case can pin that receipt (or its absence) + * without changing what this helper resolves to. + */ + canonicalReceipts?: Array +}): Promise { + mockDatabase() + const outputs: ToolResultOutput[][] = [] + spyOn(mainPromptModule, 'callMainPrompt').mockImplementation( + async ( + promptParams: Parameters[0], + ) => { + const { requestToolCall, sendAction, promptId } = promptParams + const sessionState = getInitialSessionState(getStubProjectFileContext()) + for (const call of params.calls) { + const handled = await requestToolCall({ + userInputId: promptId, + toolName: call.toolName, + input: call.input, + }) + outputs.push(handled.output) + params.canonicalReceipts?.push(handled.canonicalReceipt) + } + await sendAction({ + action: { + type: 'prompt-response', + promptId, + sessionState, + output: { type: 'lastMessage', value: [] }, + }, + }) + return { + sessionState, + output: { type: 'lastMessage' as const, value: [] }, + } + }, + ) + + const client = new OpenbuffClient({ + apiKey: 'test-key', + handleEvent: () => {}, + // Skip live project discovery; the mock filesystem only holds the files + // these dispatch cases touch. + projectFiles: {}, + harnessStateDir, + ...params.clientOptions, + }) + await client.run({ agent: 'base2', prompt: 'dispatch' }) + return outputs +} + +function jsonValue(output: ToolResultOutput[] | undefined): unknown { + const part = output?.[0] + return part?.type === 'json' ? part.value : undefined +} + +// Every dispatch case below writes journal state under this root, so it is +// removed once, after the whole file. +afterAll(() => { + rmSync(harnessStateDir, { recursive: true, force: true }) +}) + +describe('write_audit_findings dispatch advances workspace state', () => { + afterEach(() => { + mock.restore() + }) + + it('notifies onFilesystemMutation for the findings artifact write', async () => { + const fs: CodebuffFileSystem = createMockFs() + const events: FilesystemMutationEvent[] = [] + const canonicalReceipts: Array = [] + + const outputs = await dispatchToolCalls({ + calls: [ + { toolName: 'write_audit_findings', input: snapshotBoundAuditInput }, + ], + canonicalReceipts, + clientOptions: { + cwd: '/repo', + fsSource: fs, + onFilesystemMutation: (event) => { + events.push(event) + }, + }, + }) + + // The tool still publishes its own compact receipt, not the mutation + // payload — including the structural attestation for a snapshot-bound call, + // which only the real dispatch path can produce end to end. + expect(jsonValue(outputs[0])).toMatchObject({ + artifactPath: auditArtifactPath, + artifacts: [auditArtifactPath], + findingCount: 0, + structuralReceipt: { + schema_version: 1, + snapshot_id: 'snapshot-dispatch-1', + shard_id: 'runtime-1', + subsystem_ids: ['sdk'], + files: ['sdk/src/run.ts'], + domains: ['security'], + }, + }) + // ...and the underlying mutation is still surfaced to the host, so index + // invalidation sees the new artifact. + expect(events).toHaveLength(1) + const event = events[0] + if (!event) throw new Error('expected a filesystem mutation event') + expect(event).toMatchObject({ + toolName: 'write_audit_findings', + actions: [ + expect.objectContaining({ + action: 'create', + path: auditArtifactPath, + }), + ], + }) + expect(event.workspaceRevision).toBeGreaterThan(0) + expect(event.workspaceSnapshotId).toContain('workspace.v1.') + // The returned mutation also feeds the canonical-receipt fallback, so + // the compact tool output is still correlated to a committed receipt for + // the same operation the event reports. + const receipt = canonicalReceipts[0] + if (!receipt) throw new Error('expected a canonical commit receipt') + expect(receipt).toMatchObject({ + kind: 'commit_receipt', + status: 'committed', + operationId: event.operationId, + actions: [ + expect.objectContaining({ + action: 'create', + path: auditArtifactPath, + status: 'committed', + }), + ], + }) + expect(await fs.readFile(`/repo/${auditArtifactPath}`, 'utf8')).toContain( + '# Audit findings: runtime-1', + ) + }) + + it('falls back to onFilesChanged when no mutation observer is registered', async () => { + const fs: CodebuffFileSystem = createMockFs() + let fileChangeCalls = 0 + + await dispatchToolCalls({ + calls: [{ toolName: 'write_audit_findings', input: auditInput }], + clientOptions: { + cwd: '/repo', + fsSource: fs, + onFilesChanged: () => { + fileChangeCalls++ + }, + }, + }) + + expect(fileChangeCalls).toBe(1) + // Pins the notification to this artifact write: the counter alone would + // also pass for an unrelated change notification. + expect(await fs.readFile(`/repo/${auditArtifactPath}`, 'utf8')).toContain( + '# Audit findings: runtime-1', + ) + }) + + it('does not notify observers when the artifact write is rejected', async () => { + // The artifact already exists, so the exclusive create is not applied. + const fs: CodebuffFileSystem = createMockFs({ + files: { [`/repo/${auditArtifactPath}`]: 'existing\n' }, + }) + const events: FilesystemMutationEvent[] = [] + const canonicalReceipts: Array = [] + let fileChangeCalls = 0 + + const outputs = await dispatchToolCalls({ + calls: [{ toolName: 'write_audit_findings', input: auditInput }], + canonicalReceipts, + clientOptions: { + cwd: '/repo', + fsSource: fs, + onFilesystemMutation: (event) => { + events.push(event) + }, + onFilesChanged: () => { + fileChangeCalls++ + }, + }, + }) + + expect(jsonValue(outputs[0])).toMatchObject({ + artifactPath: auditArtifactPath, + errorMessage: expect.stringContaining('already exists'), + }) + expect(events).toHaveLength(0) + expect(fileChangeCalls).toBe(0) + // The mutation is returned for `not_applied` results too, so the rejected + // write still reaches run.ts's canonical-receipt fallback; it must not + // publish a committed-looking receipt for an edit that never landed. + expect(canonicalReceipts).toHaveLength(1) + expect(canonicalReceipts[0]).toBeUndefined() + expect(await fs.readFile(`/repo/${auditArtifactPath}`, 'utf8')).toBe( + 'existing\n', + ) + }) +}) + +describe('replace_range dispatch wiring', () => { + afterEach(() => { + mock.restore() + }) + + it('rejects the call when the run has no cwd (and therefore no issuer)', async () => { + // Both dispatch preconditions (`requireCwd` / `requireCapabilityIssuer`) + // are unsatisfiable without cwd: the issuer is constructed exactly when + // cwd is set, so the tool must never reach the applicator. + const outputs = await dispatchToolCalls({ + calls: [ + { + toolName: 'replace_range', + input: { + path: 'src/file.ts', + readCapability: encodeReadCapabilityToken({ + startLine: 1, + endLine: 1, + hash: getContentHash('line 1'), + scope: { + projectId: '/repo', + path: 'src/file.ts', + runId: 'some-run', + }, + }), + newContent: 'updated line 1', + }, + }, + ], + clientOptions: {}, + }) + + expect(jsonValue(outputs[0])).toMatchObject({ + errorMessage: expect.stringContaining( + 'is required for the replace_range tool', + ), + }) + }) + + it('forwards the run-scoped capability issuer to the applicator', async () => { + const original = 'line 1\nline 2\n' + const fs: CodebuffFileSystem = createMockFs({ + files: { '/repo/src/file.ts': original }, + }) + + const outputs = await dispatchToolCalls({ + calls: [ + { + toolName: 'replace_range', + input: { + path: 'src/file.ts', + readCapability: encodeReadCapabilityToken({ + startLine: 1, + endLine: 1, + hash: getContentHash('line 1'), + scope: { + projectId: '/repo', + path: 'src/file.ts', + // Not this run's id, so the scope check must reject it. + runId: 'foreign-run', + }, + }), + newContent: 'updated line 1', + }, + }, + ], + clientOptions: { cwd: '/repo', fsSource: fs }, + }) + + // Reaching the scope check proves the dispatch passed both cwd and the + // run-scoped issuer through: a missing issuer would have failed earlier + // with the `requireCapabilityIssuer` error instead. + expect(jsonValue(outputs[0])).toMatchObject({ + file: 'src/file.ts', + errorMessage: expect.stringContaining( + 'different project, path, or agent run', + ), + }) + expect(await fs.readFile('/repo/src/file.ts', 'utf8')).toBe(original) + }) + + it('applies a range edit when the capability scope matches the run and notifies observers', async () => { + const original = 'line 1\nline 2\nline 3\n' + const fs: CodebuffFileSystem = createMockFs({ + files: { '/repo/src/file.ts': original }, + }) + const events: FilesystemMutationEvent[] = [] + let workspaceState: WorkspaceStateV1 | undefined + const capabilityIssuer = { projectId: '/repo', runId: 'run-1' } + const readCapability = encodeReadCapabilityToken({ + startLine: 1, + endLine: 1, + hash: getContentHash('line 1'), + scope: { projectId: '/repo', path: 'src/file.ts', runId: 'run-1' }, + }) + const handled = await handleToolCall({ + action: { + type: 'tool-call-request', + requestId: 'call-replace-range-applied', + userInputId: 'input-1', + toolName: 'replace_range', + input: { + path: 'src/file.ts', + readCapability, + newContent: 'updated line 1', + }, + }, + overrides: {}, + customToolDefinitions: {}, + cwd: '/repo', + fs, + trustedJobOwner: { + clientSessionId: 'session-1', + rootRunId: 'run-1', + parentRunId: 'run-1', + parentAgentId: 'agent-1', + }, + capabilityIssuer, + harnessStateDir, + approvalReceiptIds: [], + approvalMode: 'balanced', + approvalService: new HarnessApprovalService( + new LocalHarnessStore(harnessStateDir), + ), + getWorkspaceState: () => workspaceState, + setWorkspaceState: (state) => { + workspaceState = state + }, + onFilesystemMutation: (event) => { + events.push(event) + }, + }) + expect(handled.output[0]).toMatchObject({ + type: 'json', + value: { kind: 'file_mutation_result', outcome: 'applied' }, + }) + expect(await fs.readFile('/repo/src/file.ts', 'utf8')).toBe( + 'updated line 1\nline 2\nline 3\n', + ) + if (!workspaceState) throw new Error('expected workspace state to be set') + expect(workspaceState.revision).toBe(1) + expect(workspaceState.snapshotId).toContain('workspace.v1.') + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + toolName: 'replace_range', + callId: 'call-replace-range-applied', + workspaceRevision: workspaceState.revision, + workspaceSnapshotId: workspaceState.snapshotId, + actions: [ + expect.objectContaining({ action: 'update', path: 'src/file.ts' }), + ], + }) + }) +}) + +/** + * Drives the exported dispatcher directly (no agent loop, no client), so the + * returned-mutation wiring for compact-receipt tools can be asserted on + * exactly the state it advances: the caller-owned workspace state, the emitted + * event, and the tool output handed back to the agent. + */ +async function dispatchAuditWriteDirectly(params: { + fs: CodebuffFileSystem + input: unknown + requestId: string + workspaceState?: WorkspaceStateV1 + onFilesystemMutation?: (event: FilesystemMutationEvent) => void + onFilesChanged?: () => void +}): Promise<{ + output: ToolResultOutput[] + workspaceState: WorkspaceStateV1 | undefined +}> { + let workspaceState = params.workspaceState + const handled = await handleToolCall({ + action: { + type: 'tool-call-request', + requestId: params.requestId, + userInputId: 'input-1', + toolName: 'write_audit_findings', + input: params.input, + }, + overrides: {}, + customToolDefinitions: {}, + cwd: '/repo', + fs: params.fs, + trustedJobOwner: { + clientSessionId: 'session-1', + rootRunId: 'run-1', + parentRunId: 'run-1', + parentAgentId: 'agent-1', + }, + harnessStateDir, + approvalReceiptIds: [], + approvalMode: 'balanced', + approvalService: new HarnessApprovalService( + new LocalHarnessStore(harnessStateDir), + ), + getWorkspaceState: () => workspaceState, + setWorkspaceState: (state) => { + workspaceState = state + }, + onFilesystemMutation: params.onFilesystemMutation, + onFilesChanged: params.onFilesChanged, + }) + return { output: handled.output, workspaceState } +} + +describe('handleToolCall compact-receipt mutation wiring', () => { + it('advances workspace state and emits the artifact actions for an applied write', async () => { + const fs: CodebuffFileSystem = createMockFs() + const events: FilesystemMutationEvent[] = [] + + const { output, workspaceState } = await dispatchAuditWriteDirectly({ + fs, + input: snapshotBoundAuditInput, + requestId: 'call-applied', + onFilesystemMutation: (event) => { + events.push(event) + }, + }) + + if (!workspaceState) throw new Error('expected advanced workspace state') + expect(workspaceState.revision).toBe(1) + expect(workspaceState.snapshotId).toStartWith('workspace.v1.1.') + expect(events).toHaveLength(1) + const event = events[0] + if (!event) throw new Error('expected a filesystem mutation event') + expect(event).toMatchObject({ + toolName: 'write_audit_findings', + callId: 'call-applied', + workspaceRevision: workspaceState.revision, + workspaceSnapshotId: workspaceState.snapshotId, + }) + // Asserted as the whole action list, so the hoisted action shaping is + // pinned: the journal record and this event are built from one array, and + // the hashes must describe the artifact that was actually created. + // Receipt hashes are byte-exact, so the expectation uses the byte-exact + // digest rather than the LF-normalized read/edit hash. + const written = await fs.readFile(`/repo/${auditArtifactPath}`, 'utf8') + expect(event.actions).toEqual([ + { + action: 'create', + path: auditArtifactPath, + beforeHash: null, + afterHash: getExactContentHash(written), + }, + ]) + // The journal record must describe the same revision the event reports. + expect(workspaceState.changes.at(-1)).toMatchObject({ + revision: workspaceState.revision, + source: 'sdk:write_audit_findings', + actions: event.actions, + }) + + // (b) The agent still receives the tool's compact declared receipt: its + // output schema declares no workspace fields, so enriching it here would + // publish coordinates a schema-parsing consumer silently strips. + const receipt = jsonValue(output) + expect(receipt).toMatchObject({ artifactPath: auditArtifactPath }) + expect(receipt).not.toHaveProperty('workspaceRevision') + expect(receipt).not.toHaveProperty('workspaceSnapshotId') + }) + + it('advances nothing and notifies nobody for a not_applied write', async () => { + // The artifact already exists, so the exclusive create is refused and the + // returned mutation carries a `not_applied` receipt: it is returned for + // every well-formed mutation, applied or not. + const fs: CodebuffFileSystem = createMockFs({ + files: { [`/repo/${auditArtifactPath}`]: 'existing\n' }, + }) + const events: FilesystemMutationEvent[] = [] + let fileChangeCalls = 0 + const priorState: WorkspaceStateV1 = { + schemaVersion: 1, + revision: 7, + snapshotId: 'workspace.v1.7.deadbeef', + updatedAt: 1, + changes: [], + } + + const { output, workspaceState } = await dispatchAuditWriteDirectly({ + fs, + input: auditInput, + requestId: 'call-not-applied', + workspaceState: priorState, + onFilesystemMutation: (event) => { + events.push(event) + }, + onFilesChanged: () => { + fileChangeCalls++ + }, + }) + + expect(jsonValue(output)).toMatchObject({ + artifactPath: auditArtifactPath, + errorMessage: expect.stringContaining('already exists'), + }) + expect(workspaceState).toBe(priorState) + expect(events).toHaveLength(0) + expect(fileChangeCalls).toBe(0) + expect(await fs.readFile(`/repo/${auditArtifactPath}`, 'utf8')).toBe( + 'existing\n', + ) + }) +}) diff --git a/sdk/src/__tests__/task-memory-store.test.ts b/sdk/src/__tests__/task-memory-store.test.ts index ecef9647b3..50093250cd 100644 --- a/sdk/src/__tests__/task-memory-store.test.ts +++ b/sdk/src/__tests__/task-memory-store.test.ts @@ -14,12 +14,10 @@ import path from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { recordToolEvidenceInTaskMemory } from '@codebuff/agent-runtime/util/task-memory' import { stableHash } from '@codebuff/common/util/stable-hash' -import { - collectWorkspaceMoves, - persistRunTaskMemory, -} from '../run' +import { collectWorkspaceMoves, persistRunTaskMemory } from '../run' import { codebuffFsToNodePromises, loadPersistedTaskMemory, @@ -27,11 +25,17 @@ import { reconcileTaskMemoryEvidence, saveMergedTaskMemory, } from '../services/task-memory-store' +import { changeFile } from '../tools/change-file' +import { createNodeFileSystem } from '../tools/node-filesystem' +import { getFilesStructured } from '../tools/read-files' import type { RunState } from '../run-state' import type { WorkspaceJournalService } from '../services/workspace-journal' import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem' -import type { TaskMemoryEvidenceV1, TaskMemoryV1 } from '@codebuff/common/types/task-memory' +import type { + TaskMemoryEvidenceV1, + TaskMemoryV1, +} from '@codebuff/common/types/task-memory' function sha256(content: string): string { return createHash('sha256').update(content).digest('hex') @@ -109,8 +113,16 @@ describe('task-memory-store', () => { await writeFile(path.join(rootDir, 'b.ts'), 'beta') const memory = makeMemory({ evidence: [ - makeEvidence({ id: 'ev-a', path: 'a.ts', freshnessHash: sha256('alpha') }), - makeEvidence({ id: 'ev-b', path: 'b.ts', freshnessHash: sha256('beta') }), + makeEvidence({ + id: 'ev-a', + path: 'a.ts', + freshnessHash: sha256('alpha'), + }), + makeEvidence({ + id: 'ev-b', + path: 'b.ts', + freshnessHash: sha256('beta'), + }), ], }) await saveMergedTaskMemory({ rootDir, runMemory: memory }) @@ -387,9 +399,7 @@ describe('task-memory-store', () => { // Each save targeted its own tmp file — a fixed `.tmp` name would // collapse these into one colliding path. - const tmpWrites = writtenPaths.filter((written) => - written.endsWith('.tmp'), - ) + const tmpWrites = writtenPaths.filter((written) => written.endsWith('.tmp')) expect(tmpWrites).toHaveLength(2) expect(new Set(tmpWrites).size).toBe(2) @@ -616,6 +626,125 @@ describe('task-memory-store', () => { }) }) +describe('recorded tool evidence round trip', () => { + // End-to-end over the REAL producer/consumer pair: a real file on disk, a + // real read_files anchor hash (or mutation afterHash), the runtime recorder, + // the store's save, and the store's reconcile. Both suites used to build their + // own hashes in-test (synthetic `sha256:aaa` in agent-runtime, bare hex here), + // so the prefix mismatch between the two packages passed both while every + // recorded entry reconciled stale on the next session. + let rootDir: string + + beforeEach(async () => { + rootDir = await mkdtemp(path.join(tmpdir(), 'task-memory-roundtrip-')) + }) + + afterEach(async () => { + await rm(rootDir, { recursive: true, force: true }) + }) + + test('a real read_files anchor hash reconciles fresh in the next session', async () => { + // LF content: the read anchor digest is LF-normalized while the store + // digests raw bytes, so the canonical committed form must reconcile. + await writeFile(path.join(rootDir, 'read-me.ts'), 'export const a = 1\n') + + const readResult = await getFilesStructured({ + filePaths: ['read-me.ts'], + cwd: rootDir, + fs: createNodeFileSystem(), + capabilityIssuer: { projectId: rootDir, runId: 'roundtrip-read' }, + }) + const readItem = readResult.results[0]! + const anchorHash = + 'editAnchor' in readItem ? readItem.editAnchor?.contentHash : undefined + expect(anchorHash).toMatch(/^sha256:[0-9a-f]{64}$/) + + const memory = recordToolEvidenceInTaskMemory({ + toolName: 'read_files', + callId: 'roundtrip-read-1', + output: [{ type: 'json', value: readResult }], + }) + const recorded = memory!.evidence.find( + (item) => item.id === 'read:read-me.ts', + )! + // Stored in the store's canonical bare-hex form, so `digest === + // item.freshnessHash` can match. + expect(recorded.freshnessHash).toBe(sha256('export const a = 1\n')) + + expect( + await saveMergedTaskMemory({ rootDir, runMemory: memory }), + ).toBeDefined() + const reconciled = await reconcileTaskMemoryEvidence({ + memory: (await loadPersistedTaskMemory({ rootDir }))!, + rootDir, + }) + const reconciledRead = reconciled.evidence.find( + (item) => item.id === 'read:read-me.ts', + )! + expect(reconciledRead.stale).toBe(false) + expect(reconciledRead.verifiedAt).toBeDefined() + + // Same evidence after the file changes must flip stale, so the assertion + // above cannot be satisfied by a hash comparison that never runs. + await writeFile(path.join(rootDir, 'read-me.ts'), 'export const a = 2\n') + const afterChange = await reconcileTaskMemoryEvidence({ + memory: (await loadPersistedTaskMemory({ rootDir }))!, + rootDir, + }) + expect( + afterChange.evidence.find((item) => item.id === 'read:read-me.ts')!.stale, + ).toBe(true) + }) + + test('a real mutation afterHash reconciles fresh in the next session', async () => { + const mutation = await changeFile({ + parameters: { + type: 'file', + path: 'edited.ts', + content: 'export const edited = true\n', + expectedHash: null, + }, + cwd: rootDir, + fs: createNodeFileSystem(), + capabilityIssuer: { projectId: rootDir, runId: 'roundtrip-edit' }, + }) + const mutationValue = + mutation[0]?.type === 'json' + ? (mutation[0].value as { + outcome: string + actions: Array<{ afterHash: string | null }> + }) + : undefined + expect(mutationValue?.outcome).toBe('applied') + expect(mutationValue!.actions[0]!.afterHash).toMatch( + /^sha256:[0-9a-f]{64}$/, + ) + + const memory = recordToolEvidenceInTaskMemory({ + toolName: 'str_replace', + callId: 'roundtrip-edit-1', + output: mutation, + }) + const recorded = memory!.evidence.find( + (item) => item.id === 'edit:edited.ts', + )! + // The mutation namespace is byte-exact rather than LF-normalized, but it is + // stored in the same bare-hex spelling the store compares against. + expect(recorded.freshnessHash).toBe(sha256('export const edited = true\n')) + + expect( + await saveMergedTaskMemory({ rootDir, runMemory: memory }), + ).toBeDefined() + const reconciled = await reconcileTaskMemoryEvidence({ + memory: (await loadPersistedTaskMemory({ rootDir }))!, + rootDir, + }) + expect( + reconciled.evidence.find((item) => item.id === 'edit:edited.ts')!.stale, + ).toBe(false) + }) +}) + describe('run integration gates', () => { test('collectWorkspaceMoves extracts moves, maps destinations, bounds to last 64', async () => { const movesRoot = await mkdtemp(path.join(tmpdir(), 'workspace-moves-')) @@ -659,7 +788,7 @@ describe('run integration gates', () => { } }) - test('persistRunTaskMemory persists success, skips error runs and missing memory', async () => { + test('persistRunTaskMemory persists success and aborted runs, skips missing memory and cwd', async () => { const gateRoot = await mkdtemp(path.join(tmpdir(), 'persist-gate-')) try { const persisted = await persistRunTaskMemory({ @@ -675,21 +804,23 @@ describe('run integration gates', () => { }) expect(persisted?.decisions).toEqual(['Keep me']) - // Error runs (also cancelled/aborted shape) must never write. + // Cancelled/aborted runs carry an `error` output but still learned real + // things, and saveMergedTaskMemory merges rather than overwrites, so the + // partial session must contribute instead of being discarded. const errored = await persistRunTaskMemory({ cwd: gateRoot, terminalState: { output: { type: 'error', message: 'boom' }, sessionState: { mainAgentState: { - taskMemory: makeMemory({ decisions: ['Poison'] }), + taskMemory: makeMemory({ decisions: ['Learned before Ctrl-C'] }), }, }, } as unknown as RunState, }) - expect(errored).toBeUndefined() + expect(errored?.decisions).toContain('Learned before Ctrl-C') - // Successful run without task memory writes nothing either. + // Successful run without task memory writes nothing. const noMemory = await persistRunTaskMemory({ cwd: gateRoot, terminalState: { @@ -699,9 +830,23 @@ describe('run integration gates', () => { }) expect(noMemory).toBeUndefined() - // And the error run did not overwrite the good record from above. + // No cwd means no store location, so nothing is written either. + const noCwd = await persistRunTaskMemory({ + terminalState: { + output: { type: 'lastMessage', value: [] }, + sessionState: { + mainAgentState: { + taskMemory: makeMemory({ decisions: ['Nowhere to go'] }), + }, + }, + } as unknown as RunState, + }) + expect(noCwd).toBeUndefined() + + // The merged record retains the earlier successful decision too. const reloaded = await loadPersistedTaskMemory({ rootDir: gateRoot }) - expect(reloaded?.decisions).toEqual(['Keep me']) + expect(reloaded?.decisions).toContain('Keep me') + expect(reloaded?.decisions).toContain('Learned before Ctrl-C') } finally { await rm(gateRoot, { recursive: true, force: true }) } @@ -1022,7 +1167,11 @@ describe('pruneStaleTaskMemoryEvidence', () => { rootDir, runMemory: makeMemory({ evidence: [ - makeEvidence({ id: 'ev-ok', path: 'ok.ts', freshnessHash: sha256('ok') }), + makeEvidence({ + id: 'ev-ok', + path: 'ok.ts', + freshnessHash: sha256('ok'), + }), ], }), }) diff --git a/sdk/src/__tests__/write-audit-findings.test.ts b/sdk/src/__tests__/write-audit-findings.test.ts index 502fd4d53b..6205cb10e4 100644 --- a/sdk/src/__tests__/write-audit-findings.test.ts +++ b/sdk/src/__tests__/write-audit-findings.test.ts @@ -1,6 +1,19 @@ import { describe, expect, test } from 'bun:test' import { createMockFs } from '@codebuff/common/testing/mocks/filesystem' +import { + auditIdentifierRule, + coverageDomainAliasRule, + coverageDomainsNonEmptyRule, + coverageEntryHygieneCrossReference, + coverageEntryHygieneRule, + coverageUniquenessCrossReference, + coverageUniquenessRule, + findingEntryHygieneRule, + noIssuesFoundRule, + snapshotCoverageCompletenessRule, + writeAuditFindingsParams, +} from '@codebuff/common/tools/params/tool/write-audit-findings' import { getContentHash } from '@codebuff/common/util/content-hash' import { @@ -54,6 +67,93 @@ describe('writeAuditFindings', () => { '## [HIGH] correctness — packages/agent-runtime/src/tools/tool-executor.ts:688', ) expect(markdown).toContain('### Files') + // The declared domains must be visible to agents that parse the Markdown + // artifact, matching structuralReceipt.domains in the JSON receipt. + expect(markdown).toContain('### Domains') + // Asserted against the slice after the heading so a regression that drops + // the domain bullets (leaving only the heading) cannot be satisfied by the + // Files/Subsystems bullets above it. + const domainsHeadingIndex = markdown.indexOf('### Domains') + const domainsBlock = markdown.slice( + domainsHeadingIndex + '### Domains'.length, + ) + for (const domain of input.coverage.domains) { + expect(domainsBlock).toContain(`- ${domain}`) + } + }) + + test('omits the Domains block when coverage.domains is omitted', () => { + const { domains: _domains, ...coverage } = input.coverage + const markdown = renderAuditFindingsMarkdown({ ...input, coverage }) + + expect(markdown).toContain('### Files') + expect(markdown).not.toContain('### Domains') + }) + + test('cannot forge a heading with a bare CR in a finding field', () => { + const markdown = renderAuditFindingsMarkdown({ + ...input, + findings: [ + { + ...input.findings[0], + title: 'Forged\r## Coverage receipt', + risk: 'Forged\r# Audit findings: other-shard', + evidence: 'Forged\r### Files', + }, + ], + }) + + // CommonMark treats a bare CR as a line ending, so no CR may survive into + // the artifact other agents parse. + expect(markdown).not.toContain('\r') + const headings = markdown + .split(/\r\n?|\n/) + .filter((line) => line.startsWith('#')) + expect( + headings.filter((line) => line === '## Coverage receipt'), + ).toHaveLength(1) + expect(headings.filter((line) => line === '### Files')).toHaveLength(1) + expect(headings.filter((line) => line.startsWith('# '))).toEqual([ + '# Audit findings: runtime-1', + ]) + }) + + test('cannot forge a coverage bullet or heading with a bare CR', () => { + // The schema rejects these values, but the renderer is the last line of + // defense for the artifact other agents parse, so the singleLine call on + // the coverage lists is pinned independently of that rejection. + const markdown = renderAuditFindingsMarkdown({ + ...input, + coverage: { + ...input.coverage, + subsystemIds: ['agent-runtime\r### Files'], + featureIds: ['tool-dispatch\n## Coverage receipt'], + files: ['a/b.ts\r- forged.ts'], + }, + }) + + expect(markdown).not.toContain('\r') + // Located as a whole line, not a substring: a forged value can plant the + // literal `## Coverage receipt` inside the header summary line, so + // substring slicing would start above the real heading and the assertions + // below would be measuring the wrong block. Slicing by line also proves + // the forged value never became a heading LINE. + const allLines = markdown.split(/\r\n?|\n/) + const receiptIndex = allLines.indexOf('## Coverage receipt') + expect(receiptIndex).toBeGreaterThan(-1) + const lines = allLines.slice(receiptIndex) + expect(lines.filter((line) => line.startsWith('#'))).toEqual([ + '## Coverage receipt', + '### Subsystems', + '### Features', + '### Files', + '### Domains', + ]) + // One bullet per declared entry: three coverage entries plus the domains. + expect(lines.filter((line) => line.startsWith('- '))).toHaveLength( + 3 + input.coverage.domains.length, + ) + expect(lines).not.toContain('- forged.ts') }) test('creates once and returns a compact receipt', async () => { @@ -61,11 +161,18 @@ describe('writeAuditFindings', () => { const artifactPath = auditFindingsArtifactPath(input) const markdown = renderAuditFindingsMarkdown(input) - const first = await writeAuditFindings({ + const firstCall = await writeAuditFindings({ parameters: input, cwd: '/repo', fs, }) + // The declared output is a compact receipt, so the underlying mutation is + // returned alongside it for hosts that advance workspace state from it. + expect(firstCall.mutation?.outcome).toBe('applied') + expect(firstCall.mutation?.actions.map((action) => action.path)).toEqual([ + artifactPath, + ]) + const first = firstCall.output const receipt = first[0]?.type === 'json' ? first[0].value : undefined expect(receipt).toEqual({ artifactPath, @@ -95,44 +202,903 @@ describe('writeAuditFindings', () => { expect(JSON.stringify(receipt)).not.toContain(input.findings[0].risk) expect(await fs.readFile(`/repo/${artifactPath}`, 'utf8')).toBe(markdown) - const second = await writeAuditFindings({ + const secondCall = await writeAuditFindings({ parameters: input, cwd: '/repo', fs, }) + // Returned for a rejected write too, so a host must gate on the outcome + // rather than on the mutation being present at all. + expect(secondCall.mutation).toBeDefined() + expect(secondCall.mutation?.outcome).not.toBe('applied') + const second = secondCall.output const collision = second[0]?.type === 'json' ? second[0].value : undefined expect(collision).toMatchObject({ artifactPath }) - expect(collision).toHaveProperty('errorMessage') + const collisionMessage = + collision && typeof collision === 'object' && 'errorMessage' in collision + ? collision.errorMessage + : undefined + expect(typeof collisionMessage).toBe('string') + expect(collisionMessage).not.toBe('') + expect(collisionMessage).toContain('the file already exists') expect(await fs.readFile(`/repo/${artifactPath}`, 'utf8')).toBe(markdown) }) test('preserves the legacy receipt shape when snapshotId is omitted', async () => { const fs = createMockFs() const { snapshotId: _snapshotId, ...legacyInput } = input - const result = await writeAuditFindings({ - parameters: { ...legacyInput, shardId: 'runtime-legacy' }, + const legacyParams = { ...legacyInput, shardId: 'runtime-legacy' } + const artifactPath = auditFindingsArtifactPath(legacyParams) + const markdown = renderAuditFindingsMarkdown(legacyParams) + const { output: result } = await writeAuditFindings({ + parameters: legacyParams, cwd: '/repo', fs, }) const receipt = result[0]?.type === 'json' ? result[0].value : undefined + // The success receipt must be asserted too: `not.toHaveProperty` alone + // also holds for the `{ artifactPath, errorMessage }` failure shape. + expect(receipt).toMatchObject({ + artifactPath, + artifacts: [artifactPath], + findingCount: 1, + contentHash: getContentHash(markdown), + }) expect(receipt).not.toHaveProperty('structuralReceipt') + expect(await fs.readFile(`/repo/${artifactPath}`, 'utf8')).toBe(markdown) }) test('does not attest to domain coverage when domains are omitted', async () => { const fs = createMockFs() const { domains: _domains, ...coverage } = input.coverage - const result = await writeAuditFindings({ + const params = { + ...input, + shardId: 'runtime-without-domains', + coverage, + } + const artifactPath = auditFindingsArtifactPath(params) + const markdown = renderAuditFindingsMarkdown(params) + const { output: result } = await writeAuditFindings({ + parameters: params, + cwd: '/repo', + fs, + }) + const receipt = result[0]?.type === 'json' ? result[0].value : undefined + + expect(receipt).toMatchObject({ + artifactPath, + artifacts: [artifactPath], + findingCount: 1, + contentHash: getContentHash(markdown), + }) + expect(receipt).not.toHaveProperty('structuralReceipt') + expect(await fs.readFile(`/repo/${artifactPath}`, 'utf8')).toBe(markdown) + }) + + test('rewrites the legacy api-abi domain alias to api-contract', async () => { + const fs = createMockFs() + const shardId = 'runtime-legacy-domain' + const artifactPath = auditFindingsArtifactPath({ + sessionSlug: input.sessionSlug, + shardId, + }) + const { output: result } = await writeAuditFindings({ parameters: { ...input, - shardId: 'runtime-without-domains', - coverage, + shardId, + findings: [{ ...input.findings[0], domain: 'api-abi' }], }, cwd: '/repo', fs, }) const receipt = result[0]?.type === 'json' ? result[0].value : undefined - expect(receipt).not.toHaveProperty('structuralReceipt') + expect(receipt).toMatchObject({ artifactPath, findingCount: 1 }) + const markdown = await fs.readFile(`/repo/${artifactPath}`, 'utf8') + expect(markdown).toContain( + `## [HIGH] api-contract — ${input.findings[0].path}:688`, + ) + expect(markdown).not.toContain('api-abi') + }) + + test('rejects noIssuesFound=true when findings are reported', async () => { + const fs = createMockFs() + const shardId = 'runtime-claims-no-issues' + const artifactPath = auditFindingsArtifactPath({ + sessionSlug: input.sessionSlug, + shardId, + }) + const { output: result } = await writeAuditFindings({ + parameters: { ...input, shardId, noIssuesFound: true }, + cwd: '/repo', + fs, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + artifactPath, + errorMessage: 'Missing or invalid write_audit_findings parameters.', + }, + }) + await expect(fs.readFile(`/repo/${artifactPath}`, 'utf8')).rejects.toThrow() + }) + + test('rejects noIssuesFound=false when findings are empty', async () => { + const fs = createMockFs() + const shardId = 'runtime-empty-findings' + const artifactPath = auditFindingsArtifactPath({ + sessionSlug: input.sessionSlug, + shardId, + }) + const { output: result } = await writeAuditFindings({ + parameters: { ...input, shardId, findings: [], noIssuesFound: false }, + cwd: '/repo', + fs, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + artifactPath, + errorMessage: 'Missing or invalid write_audit_findings parameters.', + }, + }) + await expect(fs.readFile(`/repo/${artifactPath}`, 'utf8')).rejects.toThrow() + }) + + test('rejects a zero-findings call that omits noIssuesFound', async () => { + const fs = createMockFs() + const shardId = 'runtime-omitted-flag' + const artifactPath = auditFindingsArtifactPath({ + sessionSlug: input.sessionSlug, + shardId, + }) + const { noIssuesFound: _noIssuesFound, ...legacyParams } = input + + // The flag defaults to false, so a clean shard must attest explicitly + // instead of having the claim inferred from an empty findings array. + const { output: result } = await writeAuditFindings({ + parameters: { ...legacyParams, shardId, findings: [] }, + cwd: '/repo', + fs, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + artifactPath, + errorMessage: 'Missing or invalid write_audit_findings parameters.', + }, + }) + await expect(fs.readFile(`/repo/${artifactPath}`, 'utf8')).rejects.toThrow() + }) + + test('accepts a non-empty findings call that omits noIssuesFound', async () => { + const fs = createMockFs() + const shardId = 'runtime-omitted-flag-with-findings' + const { noIssuesFound: _noIssuesFound, ...legacyParams } = input + const params = { ...legacyParams, shardId } + const artifactPath = auditFindingsArtifactPath(params) + + // Pins the accepted direction of the default: it must stay `false`, so a + // shard that reports findings without sending the flag still writes its + // artifact. A default flipped to `true` would reject this call. + const { output: result } = await writeAuditFindings({ + parameters: params, + cwd: '/repo', + fs, + }) + const receipt = result[0]?.type === 'json' ? result[0].value : undefined + + expect(receipt).toMatchObject({ + artifactPath, + artifacts: [artifactPath], + findingCount: 1, + }) + const markdown = await fs.readFile(`/repo/${artifactPath}`, 'utf8') + expect(markdown).not.toContain('No issues found') + }) + + test('rejects the legacy api-abi alias in coverage.domains', async () => { + const fs = createMockFs() + const shardId = 'runtime-legacy-coverage-domain' + const artifactPath = auditFindingsArtifactPath({ + sessionSlug: input.sessionSlug, + shardId, + }) + + // The alias is only accepted on findings[].domain; the tool description + // states this restriction because the rejection message is generic. + const { output: result } = await writeAuditFindings({ + parameters: { + ...input, + shardId, + coverage: { ...input.coverage, domains: ['api-abi'] }, + }, + cwd: '/repo', + fs, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + artifactPath, + errorMessage: 'Missing or invalid write_audit_findings parameters.', + }, + }) + await expect(fs.readFile(`/repo/${artifactPath}`, 'utf8')).rejects.toThrow() + }) + + test('rejects a repeated coverage entry at the writer boundary', async () => { + const fs = createMockFs() + const shardId = 'runtime-duplicate-coverage-entry' + const artifactPath = auditFindingsArtifactPath({ + sessionSlug: input.sessionSlug, + shardId, + }) + + // A repeated entry inflates `- Files covered: N`, the receipt's coverage + // counts, and structuralReceipt.files. One list is enough at this level: + // the schema-issue case below pins the refinement on all four lists; this + // case pins that the writer refuses the call and writes no artifact. + const { output: result } = await writeAuditFindings({ + parameters: { + ...input, + shardId, + coverage: { + ...input.coverage, + files: [...input.coverage.files, ...input.coverage.files], + }, + }, + cwd: '/repo', + fs, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + artifactPath, + errorMessage: 'Missing or invalid write_audit_findings parameters.', + }, + }) + await expect(fs.readFile(`/repo/${artifactPath}`, 'utf8')).rejects.toThrow() + }) + + test('rejects a control character in a coverage list at the writer boundary', async () => { + const fs = createMockFs() + const shardId = 'runtime-control-char-files' + const artifactPath = auditFindingsArtifactPath({ + sessionSlug: input.sessionSlug, + shardId, + }) + + // Rendering collapses line endings, but the raw value is echoed verbatim + // into structuralReceipt.files, so it is rejected at parse time instead of + // relying on the Markdown normalization. One list is enough at this level: + // the schema-level case below pins the per-field character matrix; this + // case pins that the writer refuses the call and writes no artifact. + const { output: result } = await writeAuditFindings({ + parameters: { + ...input, + shardId, + coverage: { + ...input.coverage, + files: [ + 'packages/agent-runtime/src/tools/tool-executor.ts\r- forged.ts', + ], + }, + }, + cwd: '/repo', + fs, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + artifactPath, + errorMessage: 'Missing or invalid write_audit_findings parameters.', + }, + }) + await expect(fs.readFile(`/repo/${artifactPath}`, 'utf8')).rejects.toThrow() + }) + + test('rejects control and format characters in every string coverage list', () => { + // The renderer's singleLine only collapses CR/LF, and the parsed value is + // echoed verbatim into structuralReceipt.files/subsystem_ids, so the + // hygiene regex must reject the whole control/format class on all three + // lists — including the tab and line endings the renderer would otherwise + // normalize away. + for (const field of ['subsystemIds', 'featureIds', 'files'] as const) { + for (const char of [ + '\t', + '\n', + '\r', + '\u0000', + '\u000b', + '\u000c', + '\u001b', + '\u0085', + '\u200b', + '\u2028', + '\u2029', + ]) { + const parsed = writeAuditFindingsParams.inputSchema.safeParse({ + ...input, + coverage: { + ...input.coverage, + [field]: [`a/b.ts${char}- forged.ts`], + }, + }) + + // The loop variables are asserted alongside the result so a regression + // names the offending list and character instead of `false !== true`. + expect({ field, char, success: parsed.success }).toEqual({ + field, + char, + success: false, + }) + } + } + }) + + test('rejects a coverage entry longer than its length bound', () => { + // The bound is checked on the raw value, so it is pinned here: an + // accidentally widened maxLength would let an unbounded path into the + // artifact and structuralReceipt. + const bounds = { subsystemIds: 200, featureIds: 200, files: 500 } as const + for (const [field, maxLength] of Object.entries(bounds)) { + expect( + writeAuditFindingsParams.inputSchema.safeParse({ + ...input, + coverage: { ...input.coverage, [field]: ['a'.repeat(maxLength + 1)] }, + }).success, + ).toBe(false) + expect( + writeAuditFindingsParams.inputSchema.safeParse({ + ...input, + coverage: { ...input.coverage, [field]: ['a'.repeat(maxLength)] }, + }).success, + ).toBe(true) + } + }) + + test('rejects coverage entries that differ only in surrounding whitespace', () => { + // ['a/b.ts', 'a/b.ts '] renders two identical bullets and inflates the + // counts the uniqueness rule exists to protect, so uniqueness is judged + // after coverageEntrySchema's trim — the same trimmed value the writer + // renders and echoes into the receipt. + const duplicated: Record = { + subsystemIds: ['agent-runtime', 'agent-runtime '], + featureIds: ['tool-dispatch', ' tool-dispatch'], + files: [ + 'packages/agent-runtime/src/tools/tool-executor.ts', + 'packages/agent-runtime/src/tools/tool-executor.ts ', + ], + } + for (const [field, values] of Object.entries(duplicated)) { + const parsed = writeAuditFindingsParams.inputSchema.safeParse({ + ...input, + coverage: { ...input.coverage, [field]: values }, + }) + + // Only rejection is asserted: the issue path and message are pinned once, + // by 'names the offending list when a coverage entry is repeated'. + expect(parsed.success).toBe(false) + } + }) + + test('trims a coverage entry before rendering and echoing it', async () => { + const fs = createMockFs() + const shardId = 'runtime-padded-entry' + const artifactPath = auditFindingsArtifactPath({ + sessionSlug: input.sessionSlug, + shardId, + }) + // A single padded entry is not a duplicate, so it parses: the trim in + // coverageEntrySchema is what keeps the Markdown bullet and + // structuralReceipt.files/subsystem_ids equal to the trimmed path + // evaluate_audit_coverage compares against. + const { output: result } = await writeAuditFindings({ + parameters: { + ...input, + shardId, + coverage: { + ...input.coverage, + subsystemIds: [' agent-runtime'], + files: ['packages/agent-runtime/src/tools/tool-executor.ts '], + }, + }, + cwd: '/repo', + fs, + }) + const receipt = result[0]?.type === 'json' ? result[0].value : undefined + + expect(receipt).toMatchObject({ + artifactPath, + structuralReceipt: { + subsystem_ids: ['agent-runtime'], + files: ['packages/agent-runtime/src/tools/tool-executor.ts'], + }, + }) + const markdown = await fs.readFile(`/repo/${artifactPath}`, 'utf8') + const lines = markdown.split('\n') + expect(lines).toContain( + '- packages/agent-runtime/src/tools/tool-executor.ts', + ) + expect(lines).toContain('- agent-runtime') + expect(lines).not.toContain('- agent-runtime') + expect(markdown).not.toContain('tool-executor.ts \n') + }) + + test('rejects a whitespace-only coverage entry', () => { + // The trim runs inside coverageEntrySchema, so non-emptiness is checked on + // the trimmed value: a padded-blank entry cannot become an empty coverage + // bullet or an empty structuralReceipt path. + for (const field of ['subsystemIds', 'featureIds', 'files'] as const) { + const parsed = writeAuditFindingsParams.inputSchema.safeParse({ + ...input, + coverage: { ...input.coverage, [field]: [' '] }, + }) + + // Carries the field into the assertion so a regression names the list. + expect({ field, success: parsed.success }).toEqual({ + field, + success: false, + }) + } + }) + + test('rejects an explicitly empty coverage.domains list', () => { + // `.min(1)` on an optional list is the one boundary a caller cannot infer + // from the field being optional: `[]` is rejected rather than treated as + // omitted, and the rejection message names no rule, so the boundary is + // pinned here and advertised by coverageDomainsNonEmptyRule. + expect( + writeAuditFindingsParams.inputSchema.safeParse({ + ...input, + coverage: { ...input.coverage, domains: [] }, + }).success, + ).toBe(false) + + // Omitting the field entirely stays accepted: that is the legacy shape + // that receives no structuralReceipt. + const { domains: _domains, ...coverage } = input.coverage + expect( + writeAuditFindingsParams.inputSchema.safeParse({ ...input, coverage }) + .success, + ).toBe(true) + }) + + test('names the offending list when a coverage entry is repeated', () => { + // Uniqueness tightens input that previously parsed, and the SDK writer + // collapses every failure to one generic message, so the schema issue + // itself must name the rule for callers that do surface zod issues. Each + // list interpolates its own field name, so all four are pinned here. + const duplicated: Record = { + subsystemIds: ['agent-runtime'], + featureIds: ['tool-dispatch'], + files: ['packages/agent-runtime/src/tools/tool-executor.ts'], + domains: ['security'], + } + for (const [field, values] of Object.entries(duplicated)) { + const parsed = writeAuditFindingsParams.inputSchema.safeParse({ + ...input, + coverage: { ...input.coverage, [field]: [...values, ...values] }, + }) + + expect(parsed.success).toBe(false) + expect( + parsed.error?.issues.map((issue) => ({ + path: issue.path, + message: issue.message, + })), + ).toEqual([ + { + path: ['coverage', field], + message: `List each coverage.${field} entry at most once`, + }, + ]) + } + }) + + test('keeps accepting duplicate-free shard payloads after the uniqueness tightening', () => { + // Compatibility guard for the hasNoDuplicates refinements: they narrow + // input that previously parsed, and the SDK writer collapses the failure to + // one generic message. The audit callers shipped with the repo ask a shard + // to report the coverage it actually covered instead of emitting a fixed + // payload, so the shapes that must keep parsing are the snapshot-bound one + // and the legacy one without snapshotId/domains — both duplicate-free. + expect(writeAuditFindingsParams.inputSchema.safeParse(input).success).toBe( + true, + ) + + const { snapshotId: _snapshotId, ...legacyInput } = input + const { domains: _domains, ...legacyCoverage } = input.coverage + expect( + writeAuditFindingsParams.inputSchema.safeParse({ + ...legacyInput, + coverage: legacyCoverage, + }).success, + ).toBe(true) + }) + + test('rejects a snapshotId that is over-long or control-bearing', async () => { + // snapshotId is echoed verbatim into structuralReceipt.snapshot_id, the + // same sink the coverage lists are hardened for, so it is bounded to the + // canonical identifier charset rather than any non-empty string. + const oversized = 'a'.repeat(101) + for (const snapshotId of [ + oversized, + 'snapshot-1\u0000', + 'snapshot-1\u2028forged', + 'snapshot 1', + 'snapshot/1', + ]) { + expect( + writeAuditFindingsParams.inputSchema.safeParse({ ...input, snapshotId }) + .success, + ).toBe(false) + } + + const fs = createMockFs() + const shardId = 'runtime-oversized-snapshot' + const artifactPath = auditFindingsArtifactPath({ + sessionSlug: input.sessionSlug, + shardId, + }) + const { output: result } = await writeAuditFindings({ + parameters: { ...input, shardId, snapshotId: oversized }, + cwd: '/repo', + fs, + }) + const value = result[0]?.type === 'json' ? result[0].value : undefined + + expect(value).toMatchObject({ + artifactPath, + errorMessage: 'Missing or invalid write_audit_findings parameters.', + }) + // The rejected value must not be amplified back through the error payload. + expect(JSON.stringify(value)).not.toContain(oversized) + await expect(fs.readFile(`/repo/${artifactPath}`, 'utf8')).rejects.toThrow() + }) + + test('rejects control and format characters in the finding text fields', () => { + // The renderer's singleLine only collapses CR/LF, so a NUL or U+2028 in a + // finding string would otherwise reach the Markdown artifact other agents + // parse; the schema rejects the rest of the control/format class instead. + for (const field of ['title', 'risk', 'fix', 'evidence'] as const) { + for (const char of ['\u0000', '\u001b', '\u200b', '\u2028', '\u2029']) { + expect( + writeAuditFindingsParams.inputSchema.safeParse({ + ...input, + findings: [{ ...input.findings[0], [field]: `forged${char}value` }], + }).success, + ).toBe(false) + } + + // Tabs and line breaks stay accepted: finding prose legitimately wraps + // and the writer collapses the line endings before rendering. + expect( + writeAuditFindingsParams.inputSchema.safeParse({ + ...input, + findings: [{ ...input.findings[0], [field]: 'wrapped\n\tvalue' }], + }).success, + ).toBe(true) + } + }) + + test('holds findings[].path to the single-line coverage entry rule', () => { + // A finding location is never wrapped prose, and it is rendered into the + // `## [SEVERITY] domain — path:line` heading, so it is held to the same + // hygiene rule as coverage.files rather than the prose rule: tabs and line + // breaks are rejected here even though they are accepted in title/risk/ + // fix/evidence. + for (const value of [ + 'wrapped\n\tvalue', + 'a/b.ts\t- forged.ts', + 'a/b.ts\r### Files', + 'a/b.ts\n## Coverage receipt', + 'a/b.ts\u0000', + 'a/b.ts\u2028forged', + ' ', + 'a'.repeat(501), + ]) { + expect( + writeAuditFindingsParams.inputSchema.safeParse({ + ...input, + findings: [{ ...input.findings[0], path: value }], + }).success, + ).toBe(false) + } + + // Padded values are trimmed, so the parsed path is the exact value the + // renderer puts in the finding heading. + const parsed = writeAuditFindingsParams.inputSchema.safeParse({ + ...input, + findings: [{ ...input.findings[0], path: ' packages/a/b.ts ' }], + }) + expect(parsed.success).toBe(true) + expect(parsed.data?.findings[0].path).toBe('packages/a/b.ts') + }) + + test('accepts a realistic sha256 snapshotId at the identifier bound', () => { + // inspect_codebase_structure returns a 64-character sha256 hex digest + // (hashInventory), so the bound must keep accepting that length — and the + // documented composable flow breaks silently if it is ever tightened below + // the schema's own 100-character maximum. + for (const snapshotId of ['a'.repeat(64), 'a'.repeat(100)]) { + const parsed = writeAuditFindingsParams.inputSchema.safeParse({ + ...input, + snapshotId, + }) + + expect(parsed.success).toBe(true) + expect(parsed.data?.snapshotId).toBe(snapshotId) + } + }) + + test('rejects an empty subsystemIds or files list on a snapshot-bound call', () => { + // Such a call emits structuralReceipt, whose subsystem_ids/files are held + // to `.min(1)` by evaluate_audit_coverage, so an empty list here would + // produce a receipt that tool rejects — contradicting the description's + // directly composable claim. + for (const field of ['subsystemIds', 'files'] as const) { + const parsed = writeAuditFindingsParams.inputSchema.safeParse({ + ...input, + coverage: { ...input.coverage, [field]: [] }, + }) + + expect(parsed.success).toBe(false) + expect( + parsed.error?.issues.map((issue) => ({ + path: issue.path, + message: issue.message, + })), + ).toEqual([ + { + path: ['coverage', field], + message: `List at least one coverage.${field} entry when snapshotId and coverage.domains are set: evaluate_audit_coverage rejects the resulting structuralReceipt with an empty list`, + }, + ]) + } + + // featureIds has no counterpart in structuralReceipt, so it still composes + // when empty and must not be tightened along with the other two. + expect( + writeAuditFindingsParams.inputSchema.safeParse({ + ...input, + coverage: { ...input.coverage, featureIds: [] }, + }).success, + ).toBe(true) + }) + + test('keeps accepting empty coverage lists on calls that get no structuralReceipt', () => { + // The completeness rule exists only to protect the receipt, so the two + // shapes that receive none must keep parsing: no snapshotId, and no + // coverage.domains. + const { snapshotId: _snapshotId, ...legacyInput } = input + const { domains: _domains, ...coverageWithoutDomains } = input.coverage + const emptyLists = { subsystemIds: [], files: [] } + + expect( + writeAuditFindingsParams.inputSchema.safeParse({ + ...legacyInput, + coverage: { ...input.coverage, ...emptyLists }, + }).success, + ).toBe(true) + expect( + writeAuditFindingsParams.inputSchema.safeParse({ + ...input, + coverage: { ...coverageWithoutDomains, ...emptyLists }, + }).success, + ).toBe(true) + }) + + test('states the noIssuesFound and coverage rules on the fields they govern', () => { + // Every parse failure collapses to one generic message, so the schema docs + // are the only place a rejected call can learn these rules. They live on + // the field each one governs rather than in the tool description paragraph. + // Asserted through the exported rule constants, so rewording a rule cannot + // break this suite while it still fails if a rule stops being advertised at + // all. + const shape = writeAuditFindingsParams.inputSchema.shape + expect(shape.noIssuesFound.description).toContain(noIssuesFoundRule) + expect(shape.snapshotId.description).toContain( + snapshotCoverageCompletenessRule, + ) + // Every identifier field is bound by auditIdentifierSchema, so all three + // state its charset/length/dot-segment rules through the one constant. + for (const field of ['sessionSlug', 'shardId', 'snapshotId'] as const) { + expect({ + field, + statesIdentifierRule: + shape[field].description?.includes(auditIdentifierRule) ?? false, + }).toEqual({ field, statesIdentifierRule: true }) + } + // The finding-entry hygiene levels are enforced but otherwise + // undiscoverable, so they are advertised on `findings` itself. + expect(shape.findings.description).toContain(findingEntryHygieneRule) + expect(shape.coverage.shape.domains.description).toContain( + coverageDomainsNonEmptyRule, + ) + expect(shape.coverage.description).toContain(coverageUniquenessRule) + expect(shape.coverage.shape.domains.description).toContain( + coverageDomainAliasRule, + ) + // Uniqueness is enforced on every coverage list, so the domains + // description points at the rule for callers that read only that field — + // by cross-reference, not by repeating its full text into the schema. + expect(shape.coverage.shape.domains.description).toContain( + coverageUniquenessCrossReference, + ) + expect(shape.coverage.shape.domains.description).not.toContain( + coverageUniquenessRule, + ) + // The single-line hygiene rule governs all three string coverage lists, so + // it is stated once on `coverage` and cross-referenced from each list + // instead of tripling ~60 words into the tool schema sent to the model. + expect(shape.coverage.description).toContain(coverageEntryHygieneRule) + for (const field of ['subsystemIds', 'featureIds', 'files'] as const) { + expect(shape.coverage.shape[field].description).toContain( + coverageEntryHygieneCrossReference, + ) + expect(shape.coverage.shape[field].description).not.toContain( + coverageEntryHygieneRule, + ) + // Uniqueness rejects these lists too, so each one also points at that + // rule — a caller reading only `coverage.files` must not miss it. + expect(shape.coverage.shape[field].description).toContain( + coverageUniquenessCrossReference, + ) + expect(shape.coverage.shape[field].description).not.toContain( + coverageUniquenessRule, + ) + } + + // The constant-based assertions above also hold for a gutted rule string, + // so each rule additionally pins one stable keyword of the behaviour it + // advertises: the accepted flag combination, the uniqueness requirement + // plus its whitespace-normalized comparison, the canonical domain id + // callers must use in coverage.domains, and the single-line requirement on + // the string coverage lists. + expect(shape.noIssuesFound.description).toContain('noIssuesFound=true') + expect(shape.snapshotId.description).toContain('at least one entry') + expect(shape.coverage.description).toContain('at most once') + expect(shape.coverage.description).toContain( + 'trimming surrounding whitespace', + ) + expect(shape.coverage.shape.domains.description).toContain('api-contract') + expect(shape.coverage.shape.domains.description).toContain( + 'at least one domain', + ) + expect(shape.coverage.description).toContain('single-line') + expect(shape.coverage.shape.files.description).toContain('single-line') + expect(shape.shardId.description).toContain( + 'letters, digits, dot, underscore, or dash', + ) + expect(shape.findings.description).toContain('single-line') + expect(shape.findings.description).toContain('tabs and line breaks') + + // The rules must not be duplicated back into the tool description + // paragraph... + expect(writeAuditFindingsParams.description).not.toContain( + noIssuesFoundRule, + ) + expect(writeAuditFindingsParams.description).not.toContain( + coverageDomainAliasRule, + ) + expect(writeAuditFindingsParams.description).not.toContain( + coverageUniquenessRule, + ) + // ...but it must still point a rejected caller at the field descriptions. + expect(writeAuditFindingsParams.description).toContain( + 'field descriptions of noIssuesFound and coverage', + ) + }) + + test('attests to the declared domain count when no issues are found', () => { + const markdown = renderAuditFindingsMarkdown({ + ...input, + findings: [], + noIssuesFound: true, + }) + + // The count is derived from what the caller actually attested to. + expect(markdown).toContain('No issues found across all 8 declared domains.') + expect(markdown).not.toContain( + 'No issues found across the declared domains.', + ) + expect(markdown).toContain('- Files covered: 1') + expect(markdown).not.toContain('## [') + }) + + test('claims no domain count when coverage.domains is omitted and no issues are found', () => { + const { domains: _domains, ...coverage } = input.coverage + const markdown = renderAuditFindingsMarkdown({ + ...input, + coverage, + findings: [], + noIssuesFound: true, + }) + + expect(markdown).toContain('No issues found across the declared domains.') + expect(markdown).not.toContain('No issues found across all') + }) + + test('returns the declared error shape instead of throwing on invalid input', async () => { + const fs = createMockFs() + // Missing findings/coverage: the schema rejects it, but sessionSlug and + // shardId are usable so the artifactPath is still reported. + const { output: result } = await writeAuditFindings({ + parameters: { + sessionSlug: 'audit-openbuff-2026-07', + shardId: 'runtime-1', + }, + cwd: '/repo', + fs, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + artifactPath: + '.agents/sessions/audit-openbuff-2026-07/findings/runtime-1.md', + errorMessage: 'Missing or invalid write_audit_findings parameters.', + }, + }) + }) + + test('does not echo unusable identifiers into the reported artifact path', async () => { + const fs = createMockFs() + const { output: result } = await writeAuditFindings({ + parameters: { sessionSlug: '../escape', shardId: 42 }, + cwd: '/repo', + fs, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + artifactPath: '.agents/sessions/(unparsed)/findings/(unparsed).md', + errorMessage: 'Missing or invalid write_audit_findings parameters.', + }, + }) + }) + + test('does not echo bare dot-segment identifiers into the reported artifact path', async () => { + const fs = createMockFs() + // No slash, so only the dot-segment refinement rejects these. + const { output: result } = await writeAuditFindings({ + parameters: { sessionSlug: '..', shardId: '.' }, + cwd: '/repo', + fs, + }) + + expect(result[0]).toMatchObject({ + type: 'json', + value: { + artifactPath: '.agents/sessions/(unparsed)/findings/(unparsed).md', + errorMessage: 'Missing or invalid write_audit_findings parameters.', + }, + }) + }) + + test('does not echo an over-long identifier into the reported artifact path', async () => { + const fs = createMockFs() + const oversizedSlug = 'a'.repeat(101) + const { output: result } = await writeAuditFindings({ + parameters: { sessionSlug: oversizedSlug, shardId: 'runtime-1' }, + cwd: '/repo', + fs, + }) + const value = result[0]?.type === 'json' ? result[0].value : undefined + + expect(value).toMatchObject({ + artifactPath: '.agents/sessions/(unparsed)/findings/runtime-1.md', + errorMessage: 'Missing or invalid write_audit_findings parameters.', + }) + expect(JSON.stringify(value)).not.toContain(oversizedSlug) }) }) diff --git a/sdk/src/run-state.ts b/sdk/src/run-state.ts index 27aa7ec289..a7fe3d8f67 100644 --- a/sdk/src/run-state.ts +++ b/sdk/src/run-state.ts @@ -21,7 +21,6 @@ import { loadPersistedTaskMemory, reconcileTaskMemoryEvidence, } from './services/task-memory-store' -import type { WorkspaceMoveRecord } from './services/task-memory-store' import { loadSkills } from './skills/load-skills' // Re-export for SDK consumers @@ -32,6 +31,7 @@ export { } from '@codebuff/common/constants/knowledge' import type { CustomToolDefinition } from './custom-tool' +import type { WorkspaceMoveRecord } from './services/task-memory-store' import type { AgentDefinition } from '@codebuff/common/templates/initial-agents-dir/types/agent-definition' import type { Logger } from '@codebuff/common/types/contracts/logger' import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem' @@ -148,6 +148,7 @@ type ProjectIndexInput = { fileTree: FileTreeNode[] filePaths: string[] readFile?: (filePath: string) => string | null | Promise + logger?: Logger } const MAX_DISCOVERED_PROJECT_READ_BYTES = 1_000_000 @@ -157,7 +158,7 @@ async function computeProjectIndex(params: ProjectIndexInput): Promise<{ fileTokenScores: Record tokenCallers: Record }> { - const { cwd, fileTree, filePaths, readFile } = params + const { cwd, fileTree, filePaths, readFile, logger } = params let fileTokenScores = {} let tokenCallers = {} @@ -168,7 +169,10 @@ async function computeProjectIndex(params: ProjectIndexInput): Promise<{ tokenCallers = tokenData.tokenCallers } catch (error) { // If token scoring fails, continue with empty scores - console.warn('Failed to generate parsed symbol scores:', error) + logger?.debug?.( + { error: getErrorObject(error) }, + 'Failed to generate parsed symbol scores', + ) } } @@ -177,8 +181,8 @@ async function computeProjectIndex(params: ProjectIndexInput): Promise<{ function getProjectIndexInput(params: { cwd: string - fs?: CodebuffFileSystem - logger?: Logger + fs: CodebuffFileSystem + logger: Logger projectFiles?: Record discoveredProject?: { fileTree: FileTreeNode[]; filePaths: string[] } }): ProjectIndexInput | undefined { @@ -191,17 +195,17 @@ function getProjectIndexInput(params: { fileTree: buildFileTree(filePaths), filePaths, readFile: (filePath: string) => projectFiles[filePath] || null, + logger, } } if (discoveredProject) { - if (!fs || !logger) return undefined - return { cwd, fileTree: discoveredProject.fileTree, filePaths: discoveredProject.filePaths.sort(), readFile: createDiscoveredProjectReader({ cwd, fs, logger }), + logger, } } @@ -233,8 +237,14 @@ function createDiscoveredProjectReader(params: { } } +/** + * An unknown size fails closed. `stat` is adapter-supplied, so a stat that + * omits `size` must not read past `MAX_DISCOVERED_PROJECT_READ_BYTES`: it is + * reported as over the cap (and therefore skipped) instead of as zero bytes, + * which would pull every discovered file fully into memory. + */ function getFileSize(stats: Awaited>) { - return typeof stats.size === 'number' ? stats.size : 0 + return typeof stats.size === 'number' ? stats.size : Number.POSITIVE_INFINITY } /** diff --git a/sdk/src/run.ts b/sdk/src/run.ts index 26997dfc19..0039b9ab8f 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -16,7 +16,7 @@ import { toolNames } from '@codebuff/common/tools/constants' import { fileMutationResultV1Schema, getConfirmedAppliedActionsV1, - isFileMutationResultV1, + type CommitReceiptV1, type FileMutationResultV1, } from '@codebuff/common/tools/results/filesystem' import { @@ -91,7 +91,10 @@ import { } from './tools/audit-intelligence' import { gitBranch } from './tools/git-branch' import { runFileChangeHooks } from './tools/file-change-hooks' -import { writeAuditFindings } from './tools/write-audit-findings' +import { + findFileMutationResult, + writeAuditFindings, +} from './tools/write-audit-findings' import { createNodeFileSystem } from './tools/node-filesystem' import { createToolExecutionDeadline, @@ -123,6 +126,7 @@ import type { import type { PrintModeEvent } from '@codebuff/common/types/print-mode' import type { SessionState } from '@codebuff/common/types/session-state' import type { JobOwner } from '@codebuff/common/util/job-registry' +import type { ReadCapabilityIssuer } from '@codebuff/common/util/content-hash' import type { Source } from '@codebuff/common/types/source' import type { CodebuffSpawn } from '@codebuff/common/types/spawn' import { listJobs } from './tools/list-jobs' @@ -399,11 +403,7 @@ export function collectWorkspaceMoves( const moves: { from: string; to: string }[] = [] for (const record of workspaceJournal.read().changes) { for (const action of record.actions) { - if ( - action.action === 'move' && - action.path && - action.destinationPath - ) { + if (action.action === 'move' && action.path && action.destinationPath) { moves.push({ from: action.path, to: action.destinationPath }) } } @@ -415,11 +415,15 @@ export function collectWorkspaceMoves( } /** - * Post-run persistence gate for cross-session task memory. Writes only on - * successful completion (cancelled, aborted, and errored runs always carry - * an `error` output) and merges the run's final memory over the snapshot - * hydrated at session start. Failures log at debug and never propagate. - * Exported for tests. + * Post-run persistence gate for cross-session task memory. Persists whenever a + * `cwd` is set and the run produced task memory, regardless of `output.type`: + * cancelled and aborted runs always carry an `error` output, and gating on that + * threw away everything a Ctrl-C'd session learned. This is safe because the + * value is a committed `TaskMemoryV1` produced by `commitTaskMemory` from work + * that actually happened, and `saveMergedTaskMemory` merges rather than + * overwrites — so a partial session contributes what it learned instead of + * discarding it. Do not restore an `output.type === 'error'` early return. + * Failures log at debug and never propagate. Exported for tests. */ export async function persistRunTaskMemory(params: { cwd?: string @@ -430,11 +434,7 @@ export async function persistRunTaskMemory(params: { logger?: { debug?: (obj: unknown, message: string) => void } }): Promise { const { cwd, terminalState, priorMemory, logger } = params - if ( - !cwd || - terminalState.output.type === 'error' || - !terminalState.sessionState?.mainAgentState.taskMemory - ) { + if (!cwd || !terminalState.sessionState?.mainAgentState.taskMemory) { return undefined } try { @@ -856,6 +856,7 @@ async function runOnce({ fileFilter, filesystemPolicy, trustedJobOwner, + logger, capabilityIssuer: cwd ? { projectId: cwd, @@ -1203,9 +1204,11 @@ async function runOnce({ 'Run completed after one or more client callbacks failed', ) } - // Persist task memory only on successful completion (gate lives in - // persistRunTaskMemory) so cancelled, aborted, and errored runs never - // poison the cross-session store. + // Persist task memory whenever a cwd is set. The gate lives in + // persistRunTaskMemory and deliberately ignores output.type, so cancelled, + // aborted, and errored runs still contribute the committed memory the + // session produced (saveMergedTaskMemory merges rather than overwrites). + // Do not re-add an output.type === 'error' gate here. await persistRunTaskMemory({ cwd, terminalState, @@ -1243,7 +1246,7 @@ export function applyListJobsDigestGate( const value = first.value const jobs = value && typeof value === 'object' && !Array.isArray(value) - ? ((value as { jobs?: unknown }).jobs) + ? (value as { jobs?: unknown }).jobs : undefined if (!Array.isArray(jobs)) { return { output, nextFingerprint: lastFingerprint } @@ -1373,6 +1376,18 @@ function requireCwd(cwd: string | undefined, toolName: string): string { return cwd } +function requireCapabilityIssuer( + capabilityIssuer: ReadCapabilityIssuer | undefined, + toolName: string, +): ReadCapabilityIssuer { + if (!capabilityIssuer) { + throw new Error( + `a read capability issuer is required for the ${toolName} tool. Please provide cwd in OpenbuffClientOptions or override the ${toolName} tool.`, + ) + } + return capabilityIssuer +} + async function readFiles({ filePaths, ranges, @@ -1392,7 +1407,7 @@ async function readFiles({ cwd?: string fs: CodebuffFileSystem signal: AbortSignal - capabilityIssuer?: import('@codebuff/common/util/content-hash').ReadCapabilityIssuer + capabilityIssuer?: ReadCapabilityIssuer }) { if (override) { const output = await executeOverride({ @@ -1417,7 +1432,13 @@ async function readFiles({ }) } -async function handleToolCall({ +/** + * Dispatches one client tool call and applies the post-dispatch mutation + * wiring (workspace state/journal advance plus change-observer notification). + * Exported so tests can drive that wiring directly instead of only through a + * full run. + */ +export async function handleToolCall({ action, overrides, customToolDefinitions, @@ -1427,6 +1448,7 @@ async function handleToolCall({ filesystemPolicy, trustedJobOwner, capabilityIssuer, + logger, env, harnessStateDir, approvalReceiptIds, @@ -1451,7 +1473,8 @@ async function handleToolCall({ filesystemPolicy?: FilesystemAuthorityPolicy /** Trusted owner injected into every process-job op; never model-derived. */ trustedJobOwner: JobOwner - capabilityIssuer?: import('@codebuff/common/util/content-hash').ReadCapabilityIssuer + capabilityIssuer?: ReadCapabilityIssuer + logger?: Logger env?: Record harnessStateDir: string approvalReceiptIds: string[] @@ -1474,7 +1497,10 @@ async function handleToolCall({ onFilesystemMutation?: OpenbuffClientOptions['onFilesystemMutation'] verifyExternalMutation?: OpenbuffClientOptions['verifyExternalMutation'] signal?: AbortSignal -}): Promise<{ output: ToolResultOutput[] }> { +}): Promise<{ + output: ToolResultOutput[] + canonicalReceipt?: CommitReceiptV1 +}> { const toolName = action.toolName const input = typeof action.input === 'string' @@ -1517,7 +1543,17 @@ async function handleToolCall({ } let result: ToolResultOutput[] - let canonicalReceipt: import('@codebuff/common/tools/results/filesystem').CommitReceiptV1 | undefined + let canonicalReceipt: CommitReceiptV1 | undefined + // Returned by tools whose declared output is a compact receipt rather than + // the file_mutation_result payload (write_audit_findings), so the + // post-dispatch mutation block below still observes their filesystem + // actions. It is returned for applied AND not_applied writes, so the + // `getConfirmedAppliedActionsV1` gate below — not its presence — decides + // whether anything landed. That receipt intentionally stays compact: the + // emitted FilesystemMutationEvent, which carries + // workspaceRevision/workspaceSnapshotId, is the correlation channel for the + // artifact write. + let compactReceiptMutation: FileMutationResultV1 | undefined if (!toolNames.includes(toolName as ToolName)) { const customToolHandler = customToolDefinitions[toolName] @@ -1575,6 +1611,10 @@ async function handleToolCall({ toolName === 'str_replace' || toolName === 'create_plan' || toolName === 'edit_transaction' || + // Compact-receipt mutating tool: an override for it can still return a + // `file_mutation_result` part, which would otherwise self-certify + // `applied` and advance workspace state/journal without attestation. + toolName === 'write_audit_findings' || toolName === 'replace_range' ) { result = await Promise.all( @@ -1590,9 +1630,7 @@ async function handleToolCall({ result: parsed.data, })) ) { - if ( - parsed.data.authorityReceipt?.callId === action.requestId - ) { + if (parsed.data.authorityReceipt?.callId === action.requestId) { canonicalReceipt = parsed.data.authorityReceipt } return part @@ -1650,7 +1688,7 @@ async function handleToolCall({ }, ] } else if (toolName === 'write_audit_findings') { - result = await writeAuditFindings({ + const audit = await writeAuditFindings({ parameters: input, cwd: requireCwd(cwd, toolName), fs, @@ -1658,7 +1696,10 @@ async function handleToolCall({ fileFilter, filesystemPolicy, callId: action.requestId, + logger, }) + result = audit.output + compactReceiptMutation = audit.mutation } else if ( toolName === 'write_file' || toolName === 'str_replace' || @@ -1671,7 +1712,9 @@ async function handleToolCall({ signal, fileFilter, filesystemPolicy, + capabilityIssuer, callId: action.requestId, + logger, }) } else if (toolName === 'edit_transaction') { result = await changeFiles({ @@ -1681,7 +1724,9 @@ async function handleToolCall({ signal, fileFilter, filesystemPolicy, + capabilityIssuer, callId: action.requestId, + logger, }) } else if (toolName === 'replace_range') { result = await replaceRange({ @@ -1691,12 +1736,12 @@ async function handleToolCall({ signal, fileFilter, filesystemPolicy, - capabilityIssuer: - capabilityIssuer ?? - (() => { - throw new Error('replace_range requires a scoped capability issuer') - })(), + // The issuer is constructed exactly when `cwd` is set; assert it + // explicitly rather than depending on evaluation order of the sibling + // `cwd: requireCwd(...)` property. + capabilityIssuer: requireCapabilityIssuer(capabilityIssuer, toolName), callId: action.requestId, + logger, }) } else if (toolName === 'run_terminal_command') { const projectRoot = requireCwd(cwd, 'run_terminal_command') @@ -2129,24 +2174,21 @@ async function handleToolCall({ }, ] } - const mutation = result.find( - (part) => part.type === 'json' && isFileMutationResultV1(part.value), - ) - const mutationValue = - mutation?.type === 'json' && isFileMutationResultV1(mutation.value) - ? mutation.value - : null + // Shared selector, so this scan cannot diverge from the one the + // compact-receipt tools run over their own output parts. + const resultMutation = findFileMutationResult(result) + // Fall back to the compact-receipt channel (see its declaration). + const mutationValue = resultMutation ?? compactReceiptMutation ?? null const confirmedActions = mutationValue ? getConfirmedAppliedActionsV1(mutationValue) : [] - if (confirmedActions.length > 0) { - const workspaceChange = { - source: `sdk:${toolName}`, - operationId: mutationValue!.operationId, - ...(mutationValue!.receiptId - ? { receiptId: mutationValue!.receiptId } - : {}), - actions: confirmedActions.map((confirmed) => ({ + // `mutationValue` is re-tested here so TypeScript narrows it for the whole + // block below instead of repeating non-null assertions on every use. + if (mutationValue && confirmedActions.length > 0) { + // Shaped once and reused below, so the journal record and the emitted + // event can never describe different actions. + const changedActions: FilesystemMutationEvent['actions'] = + confirmedActions.map((confirmed) => ({ action: confirmed.action, path: confirmed.path, ...(confirmed.destinationPath @@ -2154,61 +2196,68 @@ async function handleToolCall({ : {}), beforeHash: confirmed.beforeHash, afterHash: confirmed.afterHash, - })), + })) + const workspaceChange = { + source: `sdk:${toolName}`, + operationId: mutationValue.operationId, + ...(mutationValue.receiptId + ? { receiptId: mutationValue.receiptId } + : {}), + actions: changedActions, } const workspaceState = advanceWorkspaceJournal ? advanceWorkspaceJournal(workspaceChange) : advanceWorkspaceState(getWorkspaceState(), workspaceChange) setWorkspaceState(workspaceState) - const enrichedMutation = fileMutationResultV1Schema.parse({ - ...mutationValue, - workspaceRevision: workspaceState.revision, - workspaceSnapshotId: workspaceState.snapshotId, - ...(mutationValue!.authorityReceipt - ? { - authorityReceipt: { - ...mutationValue!.authorityReceipt, - workspaceRevision: workspaceState.revision, - workspaceSnapshotId: workspaceState.snapshotId, - }, - } - : {}), - }) - result = result.map((part) => - part.type === 'json' && part.value === mutationValue - ? { type: 'json' as const, value: enrichedMutation } - : part, - ) + // Only a mutation that was actually part of `result` can be enriched in + // place. Tools whose declared output is a compact receipt reach this block + // through `compactReceiptMutation`; their receipt schema declares no + // workspace fields (a consumer parsing it through `outputSchema` would + // strip them), so those hosts read the coordinates from the + // FilesystemMutationEvent emitted below instead. + if (resultMutation) { + const enrichedMutation = fileMutationResultV1Schema.parse({ + ...mutationValue, + workspaceRevision: workspaceState.revision, + workspaceSnapshotId: workspaceState.snapshotId, + ...(mutationValue.authorityReceipt + ? { + authorityReceipt: { + ...mutationValue.authorityReceipt, + workspaceRevision: workspaceState.revision, + workspaceSnapshotId: workspaceState.snapshotId, + }, + } + : {}), + }) + result = result.map((part) => + part.type === 'json' && part.value === mutationValue + ? { type: 'json' as const, value: enrichedMutation } + : part, + ) + } const event: FilesystemMutationEvent = { toolName, callId: action.requestId, - operationId: mutationValue!.operationId, - ...(mutationValue!.receiptId - ? { receiptId: mutationValue!.receiptId } + operationId: mutationValue.operationId, + ...(mutationValue.receiptId + ? { receiptId: mutationValue.receiptId } : {}), workspaceRevision: workspaceState.revision, workspaceSnapshotId: workspaceState.snapshotId, - actions: confirmedActions.map((confirmed) => ({ - action: confirmed.action, - path: confirmed.path, - ...(confirmed.destinationPath - ? { destinationPath: confirmed.destinationPath } - : {}), - beforeHash: confirmed.beforeHash, - afterHash: confirmed.afterHash, - })), + actions: changedActions, } if (onFilesystemMutation) { try { await onFilesystemMutation(event) } catch (error) { - console.warn('[openbuff] filesystem mutation observer failed', error) + logger?.warn({ error }, 'Filesystem mutation observer failed') try { await onFilesChanged?.() } catch (fallbackError) { - console.warn( - '[openbuff] file-change fallback observer failed', - fallbackError, + logger?.warn( + { error: fallbackError }, + 'File-change fallback observer failed', ) } } @@ -2216,7 +2265,7 @@ async function handleToolCall({ try { await onFilesChanged?.() } catch (error) { - console.warn('[openbuff] file-change observer failed', error) + logger?.warn({ error }, 'File-change observer failed') } } } else if ( @@ -2227,7 +2276,7 @@ async function handleToolCall({ try { await onFilesChanged?.() } catch (error) { - console.warn('[openbuff] unknown-mutation observer failed', error) + logger?.warn({ error }, 'Unknown-mutation observer failed') } } if (!canonicalReceipt && mutationValue && cwd) { diff --git a/sdk/src/tools/change-file.ts b/sdk/src/tools/change-file.ts index b8b30a6770..db965c65f0 100644 --- a/sdk/src/tools/change-file.ts +++ b/sdk/src/tools/change-file.ts @@ -12,6 +12,7 @@ import { import { fileExists } from '@codebuff/common/util/file' import { getContentHash, + getExactContentHash, type ReadCapabilityIssuer, } from '@codebuff/common/util/content-hash' import { @@ -32,6 +33,7 @@ import { buildFreshWholeFileMutationAuthority } from './mutation-capabilities' import type { CodebuffToolOutput } from '@codebuff/common/tools/list' import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem' +import type { Logger } from '@codebuff/common/types/contracts/logger' import type { ResolvedOperationPath } from './path-utils' import type { FileFilter } from './read-files' import type { FilesystemAuthorityPolicy } from './filesystem-authority' @@ -53,8 +55,52 @@ type ApplyChangeResult = file: string patch: string error: FilesystemError + exists: boolean } - | { status: 'invalid'; file: string; error: FilesystemError } + | { + status: 'invalid' + file: string + error: FilesystemError + /** + * Target state used to report the attempted action: the in-lock + * observation when the mutation was rejected inside the lock, and `false` + * when path policy blocked the mutation before anything was observed. + */ + exists: boolean + } + +/** Upper bound on any raw model-supplied value echoed into a diagnostic. */ +const MAX_ECHOED_VALUE_LENGTH = 500 + +/** + * Placeholder reported instead of a model-supplied value that cannot be echoed + * back verbatim. Shared by `replace_range`'s path echo and + * `write_audit_findings`' artifact-identifier echo so the placeholder wording + * cannot drift between them. + */ +export const UNREPORTABLE_ECHO = '(unparsed)' + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index) + if (code < 0x20 || code === 0x7f) return true + } + return false +} + +/** + * Bounded, single-line echo of raw model input for an agent-facing diagnostic: + * an oversized value cannot amplify the message, and a value carrying CR/LF or + * other control characters cannot forge extra lines in it. Anything unusable is + * reported as `UNREPORTABLE_ECHO` instead. + */ +export function boundedDiagnosticEcho(value: unknown): string { + return typeof value === 'string' && + value.length <= MAX_ECHOED_VALUE_LENGTH && + !hasControlCharacter(value) + ? value + : UNREPORTABLE_ECHO +} export async function changeFile(params: { parameters: unknown @@ -65,6 +111,15 @@ export async function changeFile(params: { callId?: string filesystemPolicy?: FilesystemAuthorityPolicy capabilityIssuer?: ReadCapabilityIssuer + /** + * Optional byte-exact companion to `parameters.expectedHash`. `expectedHash` + * is LF-normalized, so callers that derived their new content from raw bytes + * read outside this function's lock (`replace_range`'s range splice) pass the + * byte-exact hash of what they read as well, and the commit is refused when + * only the line terminators changed underneath them. + */ + expectedExactHash?: string | null + logger?: Logger }): Promise> { const { parameters, @@ -75,6 +130,8 @@ export async function changeFile(params: { callId, filesystemPolicy, capabilityIssuer, + expectedExactHash, + logger, } = params const fileChange = FileContentChangeSchema.parse(parameters) @@ -96,6 +153,8 @@ export async function changeFile(params: { fileFilter, callId, filesystemPolicy, + expectedExactHash, + logger, }) if (result.status === 'created' || result.status === 'modified') { @@ -151,6 +210,20 @@ export async function changeFile(params: { 'error' in result ? result.error : filesystemError('application_rejected', 'Mutation did not apply.') + // An unguarded write (`expectedHash === undefined`) can target either an + // existing or a missing path, so the reported action is derived from the + // target state `applyChange` observed when it rejected the mutation rather + // than defaulting to 'update'. Guarded changes keep the action implied by + // the guard the caller asked for. + const targetExisted = 'exists' in result ? result.exists : false + const attemptedAction = + fileChange.expectedHash === null + ? 'create' + : fileChange.expectedHash !== undefined + ? 'update' + : targetExisted + ? 'update' + : 'create' return [ { type: 'json', @@ -163,8 +236,8 @@ export async function changeFile(params: { { actionId: `${operationId}:0`, index: 0, - action: fileChange.expectedHash === null ? 'create' : 'update', - path: fileChange.path, + action: attemptedAction, + path: resolvedPath.relativePath, outcome: 'not_applied', beforeHash: null, afterHash: null, @@ -189,6 +262,7 @@ export async function changeFiles(params: { callId?: string filesystemPolicy?: FilesystemAuthorityPolicy capabilityIssuer?: ReadCapabilityIssuer + logger?: Logger }): Promise> { const { parameters, @@ -199,6 +273,7 @@ export async function changeFiles(params: { callId, filesystemPolicy, capabilityIssuer, + logger, } = params const parsedChanges = CHANGES.safeParse(parameters) if (!parsedChanges.success) { @@ -388,7 +463,7 @@ export async function changeFiles(params: { ), }) } - for (const entry of authorized) { + for (const [entryIndex, entry] of authorized.entries()) { const sourceOperation = entry.change.type === 'delete' ? 'delete' @@ -408,7 +483,7 @@ export async function changeFiles(params: { operationId, changes, authorityTier: tier, - failedIndex: prepared.length, + failedIndex: entryIndex, error: filesystemError( 'blocked', 'Transaction commit denied by policy.', @@ -426,7 +501,7 @@ export async function changeFiles(params: { operationId, changes, authorityTier: tier, - failedIndex: prepared.length, + failedIndex: entryIndex, error: filesystemError( 'blocked', 'Move destination commit denied by policy.', @@ -652,6 +727,19 @@ export async function changeFiles(params: { : {}), })), }) + logger?.error( + { + operationId, + receiptId: receipt.receiptId, + status: receipt.status, + code: commitError.code, + paths: prepared.map((change) => change.path), + committedCount: committed.length, + rollbackFailureCount: rollbackFailures.size, + rollbackRestoredCount: rollbackRestored.size, + }, + 'File transaction commit failed', + ) return [ { type: 'json', @@ -1155,6 +1243,8 @@ async function applyChange(params: { fileFilter?: FileFilter callId?: string filesystemPolicy?: FilesystemAuthorityPolicy + expectedExactHash?: string | null + logger?: Logger }): Promise { const { change, @@ -1165,6 +1255,8 @@ async function applyChange(params: { fileFilter, callId, filesystemPolicy, + expectedExactHash, + logger, } = params const { content, type } = change const { operationPath: fullPath, relativePath } = resolvedPath @@ -1174,14 +1266,23 @@ async function applyChange(params: { fileFilter, filesystemPolicy, ) + const authorizedOperation = + change.expectedHash === null ? ('create' as const) : ('overwrite' as const) const authorization = await authority.authorizePath( change.path, - change.expectedHash === null ? 'create' : 'overwrite', + authorizedOperation, ) if (!authorization.allowed) { return { status: 'invalid', file: relativePath, + // Path policy blocked the mutation before any lock, read, or snapshot, so + // nothing about the target was observed. Report the unobserved state + // instead of issuing a second filesystem probe on a path the policy just + // refused: an unguarded write to a blocked path is reported as the + // 'create' it is not known to overwrite, and a guarded change still keeps + // the action implied by its own guard below. + exists: false, error: filesystemError( 'blocked', `Mutation denied for ${relativePath}: ${authorization.code}.`, @@ -1191,10 +1292,13 @@ async function applyChange(params: { const operationId = crypto.randomUUID() authority.registerOperation({ id: operationId, - kind: change.expectedHash === null ? 'create' : 'overwrite', + kind: authorizedOperation, paths: [authorization.path], }) + // Carries the in-lock observation out to the failure paths below so no + // caller has to re-stat the target after the lock is released. + let observedExists = false try { return await authority.withAuthorizedPathLocks( [authorization.path], @@ -1210,6 +1314,7 @@ async function applyChange(params: { ) } const exists = initialSnapshot.state === 'present' + observedExists = exists const oldContent = exists ? await fs.readFile(fullPath, 'utf-8') : null const beforeHash = initialSnapshot.state === 'present' ? initialSnapshot.hash : null @@ -1235,11 +1340,35 @@ async function applyChange(params: { ), ) } + // `expectedHash` above is LF-normalized, so a purely CRLF<->LF + // external rewrite passes it. A caller that spliced raw bytes read + // outside this lock also pins the byte-exact content it observed, so + // such a rewrite cannot be silently overwritten with the terminators + // that read saw. + if ( + expectedExactHash !== undefined && + expectedExactHash !== + (oldContent === null ? null : getExactContentHash(oldContent)) + ) { + throw new MutationApplicationError( + filesystemError( + 'stale_state', + `Update rejected for ${relativePath}: the exact bytes changed after the file was read.`, + { + retryable: true, + requiresFreshRead: true, + recovery: 'read_again', + }, + ), + ) + } if (type === 'patch' && oldContent === null) { + authority.cancel(operationId) return { status: 'patchFailed', file: relativePath, patch: content, + exists, error: filesystemError( 'not_found', `Patch target ${relativePath} does not exist.`, @@ -1251,10 +1380,12 @@ async function applyChange(params: { const newContent = type === 'file' ? content : applyPatch(oldContent ?? '', content) if (newContent === false) { + authority.cancel(operationId) return { status: 'patchFailed', file: relativePath, patch: content, + exists, error: filesystemError( 'application_rejected', `Patch context did not match ${relativePath}.`, @@ -1274,7 +1405,6 @@ async function applyChange(params: { throw new Error(`Commit denied: ${commitAuthorization.code}`) } if (signal?.aborted) { - authority.cancel(operationId) throw signal.reason instanceof Error ? signal.reason : new Error('Mutation cancelled before commit') @@ -1370,6 +1500,12 @@ async function applyChange(params: { }, ) } catch (error) { + // Release the still-open registration on every pre-commit failure path + // (unavailable snapshot, stale/already-exists guard, denied commit, failed + // lease start, cancellation). `cancel` only transitions an 'open' + // operation, so it is a no-op once a commit lease was begun and finished, + // and `pruneTerminalOperations` can then reclaim the entry. + authority.cancel(operationId) const filesystemFailure = error instanceof MutationApplicationError ? error.filesystemError @@ -1378,17 +1514,23 @@ async function applyChange(params: { signal?.aborted ? `Mutation cancelled for ${relativePath}.` : `Mutation failed for ${relativePath}: ${error instanceof Error ? error.message : String(error)}`, - signal?.aborted - ? { retryable: true, recovery: 'retry' } - : { retryable: true, recovery: 'retry' }, + { retryable: true, recovery: 'retry' }, ) - console.error('File mutation failed', { - path: relativePath, - type, - byteLength: Buffer.byteLength(content), - code: filesystemFailure.code, - }) - return { status: 'invalid', file: relativePath, error: filesystemFailure } + logger?.error( + { + path: relativePath, + type, + byteLength: Buffer.byteLength(content), + code: filesystemFailure.code, + }, + 'File mutation failed', + ) + return { + status: 'invalid', + file: relativePath, + exists: observedExists, + error: filesystemFailure, + } } } diff --git a/sdk/src/tools/replace-range.ts b/sdk/src/tools/replace-range.ts index 0f91a13fd4..d8c7fff813 100644 --- a/sdk/src/tools/replace-range.ts +++ b/sdk/src/tools/replace-range.ts @@ -1,16 +1,19 @@ +import { MAX_TRANSACTION_FILE_BYTES } from '@codebuff/common/actions' import { replaceRangeParams } from '@codebuff/common/tools/params/tool/replace-range' import { decodeReadCapabilityToken, getContentHash, + getExactContentHash, normalizeLineEndings, readCapabilityMatchesScope, } from '@codebuff/common/util/content-hash' import { resolveFilePathForFileSystemOperation } from './path-utils' -import { changeFile } from './change-file' +import { boundedDiagnosticEcho, changeFile } from './change-file' import type { CodebuffToolOutput } from '@codebuff/common/tools/list' import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem' import type { ReadCapabilityIssuer } from '@codebuff/common/util/content-hash' +import type { Logger } from '@codebuff/common/types/contracts/logger' import type { FileFilter } from './read-files' import type { FilesystemAuthorityPolicy } from './filesystem-authority' @@ -21,14 +24,61 @@ function errorResult( return [{ type: 'json', value: { file, errorMessage } }] } -function getDisplayLineCount( - lines: string[], - normalizedContent: string, -): number { - if (normalizedContent.length === 0) return 0 +/** + * Visible line count: `split('\n')` reports a trailing empty entry for content + * ending in a newline, and `['']` for empty content, so both collapse here. + */ +function getDisplayLineCount(lines: string[]): number { return lines.at(-1) === '' ? lines.length - 1 : lines.length } +/** + * Raw span of the 1-indexed range [startLine, endLine], ending just past + * endLine's text and before its terminator. Normalization only rewrites + * newlines, so raw terminators can be spliced around verbatim; the walk (and + * therefore `sawCrlf`) stops at endLine. + */ +function getRawRangeSpan( + content: string, + lines: string[], + startLine: number, + endLine: number, +): { start: number; end: number; sawCrlf: boolean } { + let cursor = 0 + let start = 0 + let end = 0 + let sawCrlf = false + for (let index = 0; index < endLine; index++) { + if (index === startLine - 1) start = cursor + end = cursor + lines[index]!.length + if (content.startsWith('\r\n', end)) { + sawCrlf = true + cursor = end + 2 + } else { + cursor = end < content.length ? end + 1 : end + } + } + return { start, end, sawCrlf } +} + +/** + * Terminator for multi-line newContent, preferred from inside the replaced + * span, then from the terminator ending it, then from the CRLF seen up to + * endLine. `normalizeLineEndings` maps only \r\n, so CR-only files are out of + * scope: a lone CR is content, not a terminator. + */ +function getRangeLineEnding( + content: string, + span: { start: number; end: number; sawCrlf: boolean }, +): '\r\n' | '\n' { + const replaced = content.slice(span.start, span.end) + if (replaced.includes('\r\n')) return '\r\n' + if (replaced.includes('\n')) return '\n' + if (content.startsWith('\r\n', span.end)) return '\r\n' + if (content.startsWith('\n', span.end)) return '\n' + return span.sawCrlf ? '\r\n' : '\n' +} + export async function replaceRange(params: { parameters: unknown cwd: string @@ -38,10 +88,26 @@ export async function replaceRange(params: { fileFilter?: FileFilter callId?: string filesystemPolicy?: FilesystemAuthorityPolicy + logger?: Logger }): Promise> { const parsed = replaceRangeParams.inputSchema.safeParse(params.parameters) if (!parsed.success) { - return errorResult('', 'Missing or invalid replace_range parameters.') + // Echo a best-effort, length-bounded path so the agent can tell which call + // failed even though the input never parsed. + const rawParameters = + typeof params.parameters === 'object' && params.parameters !== null + ? (params.parameters as Record) + : null + return errorResult( + // No path supplied stays the empty path; any other unusable value becomes + // `boundedDiagnosticEcho`'s obviously synthetic `(unparsed)` sentinel, so + // unparsed model input can neither amplify the message nor inject fake + // lines into it, and the agent can still tell the two apart. + rawParameters?.path === undefined + ? '' + : boundedDiagnosticEcho(rawParameters.path), + 'Missing or invalid replace_range parameters.', + ) } const input = parsed.data @@ -51,10 +117,18 @@ export async function replaceRange(params: { params.fs, ) if (!resolvedPath) { - return errorResult(input.path, 'file path is outside the project directory') + // The schema requires a non-empty string path, so the parsed value is + // echoed through the shared bounded helper directly; only the unparsed + // branch above can see a missing path. + return errorResult( + boundedDiagnosticEcho(input.path), + 'file path is outside the project directory', + ) } const { operationPath: fullPath, relativePath } = resolvedPath + // `rawInputSchema.superRefine` already rejected any undecodable or non-cap.v3 + // token, so only the scope can still mismatch; the `typeof` test is narrowing. const decoded = decodeReadCapabilityToken(input.readCapability) if ( typeof decoded === 'string' || @@ -65,9 +139,17 @@ export async function replaceRange(params: { ) { return errorResult( relativePath, - typeof decoded === 'string' - ? decoded - : `replace_range blocked: the readCapability belongs to a different project, path, or agent run. Re-read ${relativePath} in this run and copy its cap.v3 token.`, + `replace_range blocked: the readCapability belongs to a different project, path, or agent run. Re-read ${relativePath} in this run and copy its cap.v3 token.`, + ) + } + + // Occurrence targeting is resolved to absolute lines by the agent-runtime + // handler against the content it just read; re-resolving here against a + // second read could diverge, so it is rejected instead. + if (input.occurrence) { + return errorResult( + relativePath, + 'replace_range rejected: occurrence targeting must be resolved to absolute lines before the edit is applied. Re-issue the edit through the agent runtime, or pass explicit startLine/endLine.', ) } @@ -86,52 +168,46 @@ export async function replaceRange(params: { ) } - const lineEnding = oldContent.includes('\r\n') ? '\r\n' : '\n' - const normalizedOldContent = normalizeLineEndings(oldContent) - const lines = normalizedOldContent.split('\n') - const displayLineCount = getDisplayLineCount(lines, normalizedOldContent) - - // Occurrence targeting is resolved to absolute lines by the agent-runtime - // handler (`resolveOccurrenceRangeInCapabilityRange`), which resolves against - // the content it just read. The applicator deliberately does not re-resolve - // here: resolving twice against two separate reads could diverge. - // Guard before the capability-length check so an unresolved occurrence on a - // shortened file still gets the accurate "must be resolved" message rather - // than a misleading beyond-file-length error (occurrence leaves start/end - // undefined; `rawInputSchema.superRefine` also rejects occurrence combined - // with startLine/endLine). - if (input.occurrence) { + // Refuse an already-oversize target before normalizing and splitting it: no + // range edit to such a file could be committed anyway, and rejecting here + // avoids materializing the normalized copy and the line array for it. + const oldBytes = Buffer.byteLength(oldContent) + if (oldBytes > MAX_TRANSACTION_FILE_BYTES) { return errorResult( relativePath, - 'replace_range rejected: occurrence targeting must be resolved to absolute lines before the edit is applied. Re-issue the edit through the agent runtime, or pass explicit startLine/endLine.', + `replace_range rejected: ${relativePath} is already ${oldBytes} bytes, over the ${MAX_TRANSACTION_FILE_BYTES}-byte per-file limit. Split the file before editing ranges in it.`, ) } + const normalizedOldContent = normalizeLineEndings(oldContent) + const lines = normalizedOldContent.split('\n') + if ( input.capabilityStartLine > lines.length || input.capabilityEndLine > lines.length ) { + // `lines.length` is one past the visible count when the content ends in a + // newline, so the diagnostic names both bounds. + const displayLineCount = getDisplayLineCount(lines) + const maxCapabilityLine = lines.length return errorResult( relativePath, - `replace_range rejected: the capability-covered range ${input.capabilityStartLine}-${input.capabilityEndLine} is beyond the current file length (${displayLineCount} lines). Re-read the target range before editing.`, + `replace_range rejected: the capability-covered range ${input.capabilityStartLine}-${input.capabilityEndLine} is beyond the current file length (${displayLineCount} lines). Capability bounds may extend to line ${maxCapabilityLine}${maxCapabilityLine > displayLineCount ? ', the phantom final entry a read reports past the visible content' : ''}. Re-read the target range before editing.`, ) } - // After the occurrence guard, non-occurrence schema input always has numeric - // startLine/endLine (filled by the input transform from capability bounds - // when omitted). - const targetStartLine = input.startLine! - const targetEndLine = input.endLine! - if (targetStartLine > lines.length || targetEndLine > lines.length) { - return errorResult( - relativePath, - `replace_range rejected: the target range ${targetStartLine}-${targetEndLine} is beyond the current file length (${displayLineCount} lines). Re-read the target range before editing.`, - ) - } + // The schema contains the target inside the capability range and the guard + // above bounds that range, so no separate target length check is needed. + const targetStartLine = input.startLine ?? input.capabilityStartLine + const targetEndLine = input.endLine ?? input.capabilityEndLine const capabilityContent = lines .slice(input.capabilityStartLine - 1, input.capabilityEndLine) .join('\n') + // Freshness is checked LF-normalized, so a purely CRLF<->LF external rewrite + // is not detected here; any content-level change still mismatches this hash, + // and the byte-exact expectation passed to `changeFile` below refuses the + // commit if only the terminators changed. if (getContentHash(capabilityContent) !== input.capabilityHash) { return errorResult( relativePath, @@ -139,8 +215,12 @@ export async function replaceRange(params: { ) } - const currentRange = lines.slice(targetStartLine - 1, targetEndLine).join('\n') + const currentRange = lines + .slice(targetStartLine - 1, targetEndLine) + .join('\n') const normalizedNewContent = normalizeLineEndings(input.newContent) + // Both sides are LF-normalized, so a line-endings-only edit is rejected as a + // no-op: the splice below cannot apply newContent's terminator style anyway. if (currentRange === normalizedNewContent) { return errorResult( relativePath, @@ -148,19 +228,53 @@ export async function replaceRange(params: { ) } - const updatedLines = [ - ...lines.slice(0, targetStartLine - 1), - ...normalizedNewContent.split('\n'), - ...lines.slice(targetEndLine), - ] - const updatedContent = updatedLines.join('\n').replaceAll('\n', lineEnding) + // Splice on the raw content so bytes outside [startLine, endLine] keep their + // original line terminators; `changeFile` is still handed the whole file. + const rawRangeSpan = getRawRangeSpan( + oldContent, + lines, + targetStartLine, + targetEndLine, + ) + // Single-line newContent has no terminator to rewrite, so the terminator + // inference — which slices and scans the whole replaced span — is skipped. + const splicedNewContent = normalizedNewContent.includes('\n') + ? normalizedNewContent + .split('\n') + .join(getRangeLineEnding(oldContent, rawRangeSpan)) + : normalizedNewContent + const updatedContent = + oldContent.slice(0, rawRangeSpan.start) + + splicedNewContent + + oldContent.slice(rawRangeSpan.end) + // `newContent` is unbounded in the schema and `changeFile` starts with + // `FileContentChangeSchema.parse`, whose per-file byte refine *throws*. The + // same bound is checked here so an oversize edit still returns this tool's + // declared `{ file, errorMessage }` shape instead of a ZodError. + const updatedBytes = Buffer.byteLength(updatedContent) + if (updatedBytes > MAX_TRANSACTION_FILE_BYTES) { + return errorResult( + relativePath, + `replace_range rejected: the updated ${relativePath} would be ${updatedBytes} bytes, over the ${MAX_TRANSACTION_FILE_BYTES}-byte per-file limit. Split the work into smaller bounded range edits.`, + ) + } + + // `changeFile` re-reads and commits conditionally on `expectedHash` inside + // its path lock, so this read is only a pre-check and that lock closes the + // TOCTOU window. The splice above was computed from the raw bytes of this + // read while `expectedHash` is LF-normalized, so the byte-exact expectation + // is passed too: a CRLF<->LF-only external rewrite would otherwise pass + // freshness and resurrect the terminators this read observed. return changeFile({ parameters: { type: 'file', path: relativePath, content: updatedContent, - expectedHash: getContentHash(oldContent), + // `getContentHash` normalizes internally, so hashing the already + // normalized copy yields the identical digest without a second rewrite. + expectedHash: getContentHash(normalizedOldContent), }, + expectedExactHash: getExactContentHash(oldContent), cwd: params.cwd, fs: params.fs, signal: params.signal, @@ -168,5 +282,6 @@ export async function replaceRange(params: { callId: params.callId, filesystemPolicy: params.filesystemPolicy, capabilityIssuer: params.capabilityIssuer, - }) as Promise> + logger: params.logger, + }) } diff --git a/sdk/src/tools/write-audit-findings.ts b/sdk/src/tools/write-audit-findings.ts index cf8384f6fa..3bb24f76d9 100644 --- a/sdk/src/tools/write-audit-findings.ts +++ b/sdk/src/tools/write-audit-findings.ts @@ -1,11 +1,15 @@ import { toolParams } from '@codebuff/common/tools/list' -import { fileMutationResultV1Schema } from '@codebuff/common/tools/results/filesystem' +import { auditIdentifierSchema } from '@codebuff/common/tools/params/tool/write-audit-findings' +import { isFileMutationResultV1 } from '@codebuff/common/tools/results/filesystem' import { getContentHash } from '@codebuff/common/util/content-hash' -import { changeFile } from './change-file' +import { changeFile, UNREPORTABLE_ECHO } from './change-file' import type { CodebuffToolOutput } from '@codebuff/common/tools/list' +import type { FileMutationResultV1 } from '@codebuff/common/tools/results/filesystem' +import type { AuditFindingsInput } from '@codebuff/common/tools/params/tool/write-audit-findings' import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem' +import type { Logger } from '@codebuff/common/types/contracts/logger' import type { FileFilter } from './read-files' import type { FilesystemAuthorityPolicy } from './filesystem-authority' @@ -16,49 +20,117 @@ export function auditFindingsArtifactPath(params: { return `.agents/sessions/${params.sessionSlug}/findings/${params.shardId}.md` } -export function renderAuditFindingsMarkdown( - input: ReturnType, -): string { +/** + * Collapses newlines in model-supplied text so a single value cannot forge an + * extra Markdown heading (e.g. a second `## Coverage receipt` block) in the + * artifact other agents parse. CommonMark treats a bare CR as a line ending + * too, so lone CRs are collapsed alongside LF and CRLF. + */ +function singleLine(value: string): string { + return value.replace(/\r\n?|\n/g, ' ') +} + +/** + * Best-effort echo of an artifact-path identifier from raw parameters that + * never parsed. Validated with the canonical `auditIdentifierSchema` so a + * rejected value can neither widen the reported path (`../escape`, `..`) nor + * amplify the error message (a megabyte of dashes), and the bounds cannot + * drift from the tool's input schema; anything else is reported as the shared + * `UNREPORTABLE_ECHO` placeholder replace_range's path echo also uses. + */ +function rawArtifactIdentifier(parameters: unknown, key: string): string { + const value = + typeof parameters === 'object' && parameters !== null + ? (parameters as Record)[key] + : undefined + const parsed = auditIdentifierSchema.safeParse(value) + return parsed.success ? parsed.data : UNREPORTABLE_ECHO +} + +export function renderAuditFindingsMarkdown(input: AuditFindingsInput): string { + const subsystemIds = input.coverage.subsystemIds.map(singleLine) + const featureIds = input.coverage.featureIds.map(singleLine) + const files = input.coverage.files.map(singleLine) const lines = [ - `# Audit findings: ${input.shardId}`, + `# Audit findings: ${singleLine(input.shardId)}`, '', - `- Subsystems: ${input.coverage.subsystemIds.join(', ') || '(none)'}`, - `- Features: ${input.coverage.featureIds.join(', ') || '(none)'}`, - `- Files covered: ${input.coverage.files.length}`, + `- Subsystems: ${subsystemIds.join(', ') || '(none)'}`, + `- Features: ${featureIds.join(', ') || '(none)'}`, + `- Files covered: ${files.length}`, '', ] if (input.noIssuesFound) { - lines.push('No issues found across all 8 domains.', '') + // `coverage.domains` is optional, so the claim is derived from what the + // caller actually attested to instead of hardcoding a domain count. + const domainCount = input.coverage.domains?.length + lines.push( + domainCount + ? `No issues found across all ${domainCount} declared domains.` + : 'No issues found across the declared domains.', + '', + ) } for (const finding of input.findings) { - const location = `${finding.path}${finding.line ? `:${finding.line}` : ''}` + const location = `${singleLine(finding.path)}${finding.line ? `:${finding.line}` : ''}` lines.push( - `## [${finding.severity}] ${finding.domain} — ${location} — ${finding.title}`, - `- **Risk:** ${finding.risk}`, - `- **Fix:** ${finding.fix}`, - `- **Evidence:** ${finding.evidence}`, + `## [${finding.severity}] ${finding.domain} — ${location} — ${singleLine(finding.title)}`, + `- **Risk:** ${singleLine(finding.risk)}`, + `- **Fix:** ${singleLine(finding.fix)}`, + `- **Evidence:** ${singleLine(finding.evidence)}`, '', ) } lines.push('## Coverage receipt', '') - lines.push( - '### Subsystems', - ...input.coverage.subsystemIds.map((id) => `- ${id}`), - '', - ) - lines.push( - '### Features', - ...input.coverage.featureIds.map((id) => `- ${id}`), - '', - ) - lines.push( - '### Files', - ...input.coverage.files.map((file) => `- ${file}`), - '', - ) + lines.push('### Subsystems', ...subsystemIds.map((id) => `- ${id}`), '') + lines.push('### Features', ...featureIds.map((id) => `- ${id}`), '') + lines.push('### Files', ...files.map((file) => `- ${file}`), '') + if (input.coverage.domains) { + // Parity with `structuralReceipt.domains`: agents that parse the Markdown + // artifact rather than the JSON receipt must be able to see the declared + // domains too. Omitted when the caller declared none, so the block never + // implies a coverage claim that was not attested to. + lines.push( + '### Domains', + ...input.coverage.domains.map((domain) => `- ${domain}`), + '', + ) + } return lines.join('\n') } +/** + * Selects a `file_mutation_result` payload from tool output parts with the + * canonical `isFileMutationResultV1` predicate rather than by position: a + * mutating tool may emit further json parts, and first-json matching would + * report a generic failure (and hide the mutation from the host) for an + * artifact that was actually written. run.ts runs the same selection over its + * dispatch result, so both share this one implementation and cannot diverge. + */ +export function findFileMutationResult( + parts: readonly { type: string; value?: unknown }[], +): FileMutationResultV1 | undefined { + for (const part of parts) { + if (part.type === 'json' && isFileMutationResultV1(part.value)) { + return part.value + } + } + return undefined +} + +export type WriteAuditFindingsResult = { + output: CodebuffToolOutput<'write_audit_findings'> + /** + * The artifact write's underlying `file_mutation_result`. The declared + * output is a compact receipt, so hosts that key off the mutation payload + * (run.ts advances workspace state/journal and notifies the change observers + * from it) would otherwise never see the artifact write. It is returned for + * every well-formed mutation result, applied or NOT, so a receiving host must + * gate on the outcome (e.g. `getConfirmedAppliedActionsV1`) instead of + * treating its presence as proof that the write succeeded. + */ + mutation?: FileMutationResultV1 +} + export async function writeAuditFindings(params: { parameters: unknown cwd: string @@ -67,12 +139,42 @@ export async function writeAuditFindings(params: { fileFilter?: FileFilter filesystemPolicy?: FilesystemAuthorityPolicy callId?: string -}): Promise> { - const input = toolParams.write_audit_findings.inputSchema.parse( + logger?: Logger +}): Promise { + const parsed = toolParams.write_audit_findings.inputSchema.safeParse( params.parameters, ) + if (!parsed.success) { + // Mirror replace_range: return this tool's declared error shape instead of + // throwing. A throw is caught by run.ts's generic handler, which emits an + // `{ errorMessage }`-only value that does not satisfy the + // write_audit_findings output union, so the agent loses the artifactPath + // identifying which call failed. + return { + output: [ + { + type: 'json', + value: { + artifactPath: auditFindingsArtifactPath({ + sessionSlug: rawArtifactIdentifier( + params.parameters, + 'sessionSlug', + ), + shardId: rawArtifactIdentifier(params.parameters, 'shardId'), + }), + errorMessage: 'Missing or invalid write_audit_findings parameters.', + }, + }, + ], + } + } + const input = parsed.data const artifactPath = auditFindingsArtifactPath(input) const content = renderAuditFindingsMarkdown(input) + // `changeFile` only throws on a rejected change shape or an outside-project + // path, neither of which is reachable for this derived path (the slugs are + // validated and cannot contain a separator or a dot segment), so every + // failure arrives as a `not_applied` result handled below. const mutationOutput = await changeFile({ parameters: { type: 'file', @@ -86,19 +188,27 @@ export async function writeAuditFindings(params: { fileFilter: params.fileFilter, filesystemPolicy: params.filesystemPolicy, callId: params.callId, + logger: params.logger, }) - const mutationPart = mutationOutput.find((part) => part.type === 'json') - const mutation = fileMutationResultV1Schema.safeParse(mutationPart?.value) - if (!mutation.success || mutation.data.outcome !== 'applied') { - const message = mutation.success - ? mutation.data.errors.map((error) => error.message).join('; ') - : 'Audit findings artifact was not confirmed as written.' - return [ - { - type: 'json', - value: { artifactPath, errorMessage: message }, - }, - ] + const mutation = findFileMutationResult(mutationOutput) + if (!mutation || mutation.outcome !== 'applied') { + const reported = mutation + ? mutation.errors.map((error) => error.message).join('; ') + : '' + return { + output: [ + { + type: 'json', + value: { + artifactPath, + errorMessage: + reported || + 'Audit findings artifact was not confirmed as written.', + }, + }, + ], + ...(mutation ? { mutation } : {}), + } } const severityCounts = { CRITICAL: 0, @@ -107,33 +217,36 @@ export async function writeAuditFindings(params: { LOW: 0, } for (const finding of input.findings) severityCounts[finding.severity]++ - return [ - { - type: 'json', - value: { - artifactPath, - artifacts: [artifactPath], - findingCount: input.findings.length, - severityCounts, - coverage: { - subsystemCount: input.coverage.subsystemIds.length, - featureCount: input.coverage.featureIds.length, - fileCount: input.coverage.files.length, + return { + output: [ + { + type: 'json', + value: { + artifactPath, + artifacts: [artifactPath], + findingCount: input.findings.length, + severityCounts, + coverage: { + subsystemCount: input.coverage.subsystemIds.length, + featureCount: input.coverage.featureIds.length, + fileCount: input.coverage.files.length, + }, + ...(input.snapshotId && input.coverage.domains + ? { + structuralReceipt: { + schema_version: 1 as const, + snapshot_id: input.snapshotId, + shard_id: input.shardId, + subsystem_ids: input.coverage.subsystemIds, + files: input.coverage.files, + domains: input.coverage.domains, + }, + } + : {}), + contentHash: getContentHash(content), }, - ...(input.snapshotId && input.coverage.domains - ? { - structuralReceipt: { - schema_version: 1 as const, - snapshot_id: input.snapshotId, - shard_id: input.shardId, - subsystem_ids: input.coverage.subsystemIds, - files: input.coverage.files, - domains: input.coverage.domains, - }, - } - : {}), - contentHash: getContentHash(content), }, - }, - ] + ], + mutation, + } }