diff --git a/.agents/types/tools.ts b/.agents/types/tools.ts index 70d9a894f0..172984f5bd 100644 --- a/.agents/types/tools.ts +++ b/.agents/types/tools.ts @@ -961,17 +961,6 @@ export interface SpawnAgentsParams { failure_pattern?: string /** Maximum extracted failure lines to return with save_full_log (basher) */ max_failure_lines?: number - /** Array of code search queries (code-searcher) */ - searchQueries?: { - /** The pattern to search for */ - pattern: string - /** 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. */ - flags?: string | string[] - /** Optional working directory relative to project root */ - cwd?: string - /** Max results per file. Default 15 */ - maxResults?: number - }[] /** Relevant file paths to read (general-agent) */ filePaths?: string[] /** Relevant directory paths to inventory (general-agent) */ diff --git a/agents/__tests__/code-searcher.test.ts b/agents/__tests__/code-searcher.test.ts deleted file mode 100644 index b1ede488f5..0000000000 --- a/agents/__tests__/code-searcher.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, test } from 'bun:test' - -import codeSearcher from '../file-explorer/code-searcher' - -import type { AgentState } from '../types/agent-definition' - -const createMockAgentState = (): AgentState => - ({ - agentId: 'code-searcher-test', - runId: 'test-run', - parentId: undefined, - messageHistory: [], - output: undefined, - systemPrompt: '', - toolDefinitions: {}, - contextTokenCount: 0, - }) as AgentState - -const mockLogger = { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, -} - -describe('code-searcher agent', () => { - test('reports malformed params instead of silently returning empty results', () => { - const generator = codeSearcher.handleSteps!({ - agentState: createMockAgentState(), - logger: mockLogger as any, - params: {}, - }) - - const result = generator.next().value as any - - expect(result).toMatchObject({ - toolName: 'set_output', - input: { - results: [], - }, - includeToolCall: false, - }) - expect(result.input.message).toContain('No search ran') - expect(result.input.message).toContain('searchQueries') - expect(result.input.message).toContain('params') - expect(generator.next().done).toBe(true) - }) - - test('skips invalid queries and runs valid queries', () => { - const generator = codeSearcher.handleSteps!({ - agentState: createMockAgentState(), - logger: mockLogger as any, - params: { - searchQueries: [ - { flags: '-g *.ts' }, - { pattern: 'edit_transaction', flags: ['-g', '*.ts'] }, - ], - }, - }) - - expect(generator.next().value).toMatchObject({ - toolName: 'code_search', - input: { - pattern: 'edit_transaction', - flags: ['-g', '*.ts'], - }, - }) - - const output = generator.next({ - agentState: createMockAgentState(), - toolResult: [ - { - type: 'json' as const, - value: { - stdout: 'Found 1 matches\nfile.ts:\n Line 1: edit_transaction', - message: 'Exit code: 0', - }, - }, - ], - stepsComplete: true, - }).value as any - - expect(output).toMatchObject({ - toolName: 'set_output', - input: { - results: [ - { - stdout: 'Found 1 matches\nfile.ts:\n Line 1: edit_transaction', - message: 'Exit code: 0', - }, - ], - }, - includeToolCall: false, - }) - expect(output.input.message).toContain( - 'Attempted 2 queries; executed 1; rejected 1', - ) - expect(output.input.message).toContain('1 returned matches') - expect(output.input.message).toContain('Skipped 1 invalid query') - // M2.1: a heuristic digest is emitted alongside raw results. - expect(typeof output.input.digest).toBe('string') - expect(output.input.digest).toContain('1 matches across 1 file') - expect(output.input.digest).toContain('edit_transaction') - }) - - test('uses a fast/cheap model (not Sonnet) for deterministic tool execution', () => { - expect(codeSearcher.model).not.toBe('anthropic/claude-sonnet-4.5') - expect(codeSearcher.model).toBeUndefined() - }) - - test('publishes set_output programmatically, not as a model-visible tool', () => { - expect(codeSearcher.toolNames).toEqual(['code_search']) - expect(codeSearcher.programmaticToolNames).toEqual(['set_output']) - }) - - test('handleSteps can be serialized for sandbox execution', () => { - const isolatedHandleSteps = new Function( - `return (${codeSearcher.handleSteps!.toString()})`, - )() as NonNullable - - const generator = isolatedHandleSteps({ - agentState: createMockAgentState(), - logger: mockLogger as any, - params: {}, - }) - - const result = generator.next().value as any - expect(result.toolName).toBe('set_output') - expect(result.input.message).toContain('searchQueries') - }) -}) diff --git a/agents/__tests__/general-agent.test.ts b/agents/__tests__/general-agent.test.ts index 9749e9b74a..a3bba68c2f 100644 --- a/agents/__tests__/general-agent.test.ts +++ b/agents/__tests__/general-agent.test.ts @@ -63,18 +63,16 @@ describe('general-agent programmatic tools', () => { }) }) - test('prefers direct code_search and multi-query code-searcher with required params', () => { - // general-agent may call code_search directly for single-pattern work, - // and spawn code-searcher for multi-query batches with - // params.searchQueries. + test('calls code_search directly instead of delegating search', () => { + // general-agent owns ripgrep-style content search itself: code_search is + // granted directly and the code-searcher agent no longer exists, so + // several patterns mean several code_search calls. const agent = createGeneralAgent({ model: 'opus' }) expect(agent.toolNames).toContain('code_search') expect(agent.instructionsPrompt).toContain('code_search') - expect(agent.instructionsPrompt).toContain('prefer direct') - expect(agent.instructionsPrompt).toContain('multi-query') - expect(agent.instructionsPrompt).toContain('params.searchQueries') expect(agent.instructionsPrompt).not.toContain('not granted to you') + expect(agent.spawnableAgents).not.toContain('code-searcher') }) test('binds durable audit shards to composable snapshot receipts', () => { @@ -149,8 +147,16 @@ describe('general-agent programmatic tools', () => { toolResult: [], } as any) - expect(completion.done).toBe(true) - expect((completion.value as any)?.toolName).not.toBe('add_message') + // The receipt gate passes, so the always-on harvest runs: a + // structured_output agent must never leave the parent with value: null. + expect(completion.done).toBe(false) + expect(completion.value).toMatchObject({ + toolName: 'set_output', + input: { harvestedFromFallback: true }, + }) + const afterHarvest = generator.next({ toolResult: [] } as any) + expect(afterHarvest.done).toBe(true) + expect((afterHarvest.value as any)?.toolName).not.toBe('add_message') }) test('keeps rejecting when the present receipt is for a different snapshot', () => { @@ -225,7 +231,11 @@ describe('general-agent programmatic tools', () => { toolResult: [], } as any) - expect(unbound.done).toBe(true) + expect(unbound.value).toMatchObject({ + toolName: 'set_output', + input: { harvestedFromFallback: true }, + }) + expect(generator.next({ toolResult: [] } as any).done).toBe(true) }) test('breaks the audit loop after exhausting completion retries', () => { @@ -260,10 +270,679 @@ describe('general-agent programmatic tools', () => { toolName: 'add_message', }) - // Third completion step: retries exhausted -> break without add_message. + // Third completion step: retries exhausted -> harvest fallback, then break + // without add_message. expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') const final = generator.next(noReceiptStep) - expect(final.done).toBe(true) - expect((final.value as any)?.toolName).not.toBe('add_message') + expect(final.value).toMatchObject({ + toolName: 'set_output', + input: { harvestedFromFallback: true }, + }) + const afterFinalHarvest = generator.next({ toolResult: [] } as any) + expect(afterFinalHarvest.done).toBe(true) + expect((afterFinalHarvest.value as any)?.toolName).not.toBe('add_message') + }) + + test('grants the path-discovery and structured-output tools it names', () => { + const agent = createGeneralAgent({ model: 'opus' }) + + expect(agent.toolNames).toContain('glob') + expect(agent.toolNames).toContain('list_directory') + expect(agent.toolNames).toContain('read_outline') + expect(agent.toolNames).toContain('set_output') + }) + + test('reports through structured output instead of a truncated last message', () => { + const agent = createGeneralAgent({ model: 'opus' }) + + expect(agent.outputMode).toBe('structured_output') + expect(agent.outputSchema?.required).toContain('summary') + // The runtime harvest flag must be declared or set_output would reject it. + expect(agent.outputSchema?.properties).toHaveProperty( + 'harvestedFromFallback', + ) + expect(agent.instructionsPrompt).toContain('set_output') + }) + + test('persists audit findings even when snapshotId is absent', () => { + const agent = createGeneralAgent({ model: 'opus' }) + const snapshotIdParam = JSON.stringify( + agent.inputSchema?.params?.properties?.snapshotId, + ) + + expect(agent.instructionsPrompt).toContain( + 'with or without params.snapshotId', + ) + expect(agent.instructionsPrompt).toContain('still write the artifact') + expect(agent.instructionsPrompt).toContain('carries no structuralReceipt') + // Only the snapshot-bound receipt claim is conditional now; the artifact + // itself is never skipped. + expect(agent.instructionsPrompt).not.toContain( + 'do not call write_audit_findings', + ) + expect(snapshotIdParam).not.toContain('fail closed') + }) + + test('harvests a text-only final answer into set_output', () => { + const agent = createGeneralAgent({ model: 'opus' }) + const generator = agent.handleSteps!({ + prompt: 'Explain how the runtime bounds child output', + params: {}, + } as any) + + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + + const harvest = generator.next({ + stepsComplete: true, + agentState: { + output: undefined, + messageHistory: [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'hiddenFinal answer.' }, + ], + }, + ], + }, + toolResult: [], + } as any) + + expect(harvest.done).toBe(false) + expect(harvest.value).toEqual({ + toolName: 'set_output', + input: { summary: 'Final answer.', harvestedFromFallback: true }, + includeToolCall: false, + }) + expect(generator.next({ toolResult: [] } as any).done).toBe(true) + }) + + test('harvests a text-only answer on the step-cap exit too', () => { + const agent = createGeneralAgent({ model: 'opus' }) + const generator = agent.handleSteps!({ + prompt: 'Explain how the runtime bounds child output', + params: {}, + } as any) + + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + + const harvest = generator.next({ + stepsComplete: false, + hitStepCap: true, + agentState: { + output: undefined, + messageHistory: [ + { + role: 'assistant', + content: [{ type: 'text', text: 'Partial answer at the cap.' }], + }, + ], + }, + toolResult: [], + } as any) + + expect(harvest.value).toEqual({ + toolName: 'set_output', + input: { + summary: 'Partial answer at the cap.', + harvestedFromFallback: true, + }, + includeToolCall: false, + }) + expect(generator.next({ toolResult: [] } as any).done).toBe(true) + }) + + test('does not harvest when the agent already produced structured output', () => { + const agent = createGeneralAgent({ model: 'opus' }) + const generator = agent.handleSteps!({ + prompt: 'Explain how the runtime bounds child output', + params: {}, + } as any) + + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + + // An explicit set_output must never be clobbered by the fallback. + const completion = generator.next({ + stepsComplete: true, + agentState: { + output: { summary: 'Explicit answer.' }, + messageHistory: [ + { + role: 'assistant', + content: [{ type: 'text', text: 'Stale prose.' }], + }, + ], + }, + toolResult: [], + } as any) + + expect(completion.done).toBe(true) + expect((completion.value as any)?.toolName).toBeUndefined() + }) + + test('harvests the whole contiguous trailing assistant turn', () => { + const agent = createGeneralAgent({ model: 'opus' }) + const generator = agent.handleSteps!({ + prompt: 'Explain how the runtime bounds child output', + params: {}, + } as any) + + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + + const harvest = generator.next({ + stepsComplete: true, + agentState: { + output: undefined, + messageHistory: [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'An earlier turn, must be excluded.' }, + ], + }, + { role: 'user', content: [{ type: 'text', text: 'Continue.' }] }, + { + role: 'assistant', + content: [{ type: 'text', text: 'First half of the answer.' }], + }, + // Plain-string content must be harvested alongside text parts. + { role: 'assistant', content: 'Second half of the answer.' }, + ], + }, + toolResult: [], + } as any) + + // Mirrors getLastAssistantTurnMessages: the whole contiguous trailing + // assistant run, oldest-first, and nothing from before the user message. + expect(harvest.value).toEqual({ + toolName: 'set_output', + input: { + summary: 'First half of the answer.\nSecond half of the answer.', + harvestedFromFallback: true, + }, + includeToolCall: false, + }) + expect(generator.next({ toolResult: [] } as any).done).toBe(true) + }) + + test('never harvests a runtime terminal notice as the answer', () => { + const agent = createGeneralAgent({ model: 'opus' }) + const generator = agent.handleSteps!({ + prompt: 'Explain how the runtime bounds child output', + params: {}, + } as any) + + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + + // run-agent-step appends the STEP_CAP_REACHED notice as the last assistant + // message before returning hitStepCap, so it would otherwise be reported as + // the model's answer. + const harvest = generator.next({ + stepsComplete: false, + hitStepCap: true, + agentState: { + output: undefined, + messageHistory: [ + { + role: 'assistant', + content: [{ type: 'text', text: 'Partial answer at the cap.' }], + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'A tool call failed.' }], + tags: ['TOOL_CALL_ERROR'], + }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'Maximum number of steps reached.' }, + ], + tags: ['STEP_CAP_REACHED'], + }, + ], + }, + toolResult: [], + } as any) + + expect(harvest.value).toEqual({ + toolName: 'set_output', + input: { + summary: 'Partial answer at the cap.', + harvestedFromFallback: true, + }, + includeToolCall: false, + }) + expect(generator.next({ toolResult: [] } as any).done).toBe(true) + }) + + test('emits set_output with a non-empty summary when the harvest is empty', () => { + const agent = createGeneralAgent({ model: 'opus' }) + const generator = agent.handleSteps!({ + prompt: 'Explain how the runtime bounds child output', + params: {}, + } as any) + + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + + // A trailing assistant turn with no text part must still reach the parent + // as structured output rather than as value: null. + const harvest = generator.next({ + stepsComplete: true, + agentState: { + output: undefined, + messageHistory: [ + { + role: 'assistant', + content: [{ type: 'tool-call', toolName: 'read_files', input: {} }], + }, + ], + }, + toolResult: [], + } as any) + + expect(harvest.done).toBe(false) + const harvestInput = (harvest.value as any).input + expect((harvest.value as any).toolName).toBe('set_output') + expect(harvestInput.harvestedFromFallback).toBe(true) + expect(typeof harvestInput.summary).toBe('string') + expect(harvestInput.summary.length).toBeGreaterThan(0) + expect(harvestInput.summary).toContain('No answer text was produced') + // The placeholder is not an answer: this marker is what keeps the parent's + // receipt a retryable partial instead of completed with zero errors. + expect(harvestInput.noHarvestedAnswer).toBe(true) + expect(generator.next({ toolResult: [] } as any).done).toBe(true) + }) + + test('emits set_output at the step cap even when only a runtime notice remains', () => { + const agent = createGeneralAgent({ model: 'opus' }) + const generator = agent.handleSteps!({ + prompt: 'Explain how the runtime bounds child output', + params: {}, + } as any) + + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + + const harvest = generator.next({ + stepsComplete: false, + hitStepCap: true, + agentState: { + output: undefined, + messageHistory: [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'Maximum number of steps reached.' }, + ], + tags: ['STEP_CAP_REACHED'], + }, + ], + }, + toolResult: [], + } as any) + + const harvestInput = (harvest.value as any).input + expect((harvest.value as any).toolName).toBe('set_output') + expect(harvestInput.harvestedFromFallback).toBe(true) + expect(harvestInput.summary).toContain('No answer text was produced') + expect(harvestInput.summary).toContain('step cap') + // A step-capped run recovered no answer, so the marker must travel with it. + expect(harvestInput.noHarvestedAnswer).toBe(true) + expect(generator.next({ toolResult: [] } as any).done).toBe(true) + }) + + test('treats an already-existing audit artifact as an idempotent success', () => { + const agent = createGeneralAgent({ model: 'opus' }) + + // write_audit_findings only reports a collision as already persisted when + // the artifact on disk is BYTE-IDENTICAL to this call's rendered findings. + // In that one case the receipt gate is already satisfied by the marker, so + // a suffixed-shard retry would persist a duplicate for a shard whose + // findings are already on disk. + expect(agent.instructionsPrompt).toContain('byte-identical') + expect(agent.instructionsPrompt).toContain( + 'treat it as an idempotent success', + ) + expect(agent.instructionsPrompt).toContain( + 'do NOT write a second artifact under a suffixed shard id', + ) + // A collision whose contents are NOT this call's findings persists nothing, + // so the recovery is a distinct shard id, not an idempotent success. + expect(agent.instructionsPrompt).toContain("are not this call's findings") + expect(agent.instructionsPrompt).toContain( + 'persist these findings under a distinct shard id to obtain a composable coverage receipt', + ) + // The old text told the shard that any collision cleared the gate and that + // a distinct shard id was only for an intentionally different artifact. + expect(agent.instructionsPrompt).not.toContain( + 'satisfies the coverage gate', + ) + expect(agent.instructionsPrompt).not.toContain( + 'intentionally writing an additional, different artifact', + ) + expect(agent.instructionsPrompt).not.toContain( + 'retry once with a suffixed shard id', + ) + expect(agent.instructionsPrompt).not.toContain('-2') + }) + + test('breaks the audit loop on an already-persisted collision result', () => { + const agent = createGeneralAgent({ model: 'opus' }) + const artifactPath = '.agents/sessions/readiness/findings/services.md' + const generator = agent.handleSteps!({ + prompt: 'Audit service completeness', + params: { + sessionSlug: 'readiness', + shardId: 'services', + snapshotId: 'snapshot-1', + }, + } as any) + + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + + // The artifact is created exclusively, so this rejection means the shard's + // findings are already durably on disk. There is no structuralReceipt in + // this history, so without the snapshot-bound already-persisted marker the + // gate would burn both retries and exit partial. + const completion = generator.next({ + stepsComplete: true, + agentState: { + messageHistory: [ + { + role: 'tool', + content: [ + { + type: 'json', + value: { + artifactPath, + errorMessage: `Failed to create file: the file already exists. Shard id "services": this shard's findings are already persisted at ${artifactPath}; treat this as already written and do not write a duplicate.`, + alreadyPersisted: { + schema_version: 1, + shardId: 'services', + artifactPath, + snapshot_id: 'snapshot-1', + }, + }, + }, + ], + }, + ], + }, + toolResult: [], + } as any) + + // No add_message retry: the gate cleared, so the only remaining yield is + // the always-on harvest. + expect(completion.value).toMatchObject({ + toolName: 'set_output', + input: { harvestedFromFallback: true }, + }) + const afterHarvest = generator.next({ toolResult: [] } as any) + expect(afterHarvest.done).toBe(true) + expect((afterHarvest.value as any)?.toolName).not.toBe('add_message') + }) + + test('keeps rejecting an already-persisted marker for a different snapshot', () => { + const agent = createGeneralAgent({ model: 'opus' }) + const generator = agent.handleSteps!({ + prompt: 'Audit service completeness', + params: { + sessionSlug: 'readiness', + shardId: 'services', + snapshotId: 'snapshot-2', + }, + } as any) + + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + + // The snapshot binding is what stops a colliding write from claiming + // coverage for a snapshot it never evaluated. + const mismatch = generator.next({ + stepsComplete: true, + agentState: { + messageHistory: [ + { + role: 'tool', + content: [ + { + type: 'json', + value: { + artifactPath: + '.agents/sessions/readiness/findings/services.md', + errorMessage: 'the file already exists', + alreadyPersisted: { + schema_version: 1, + shardId: 'services', + artifactPath: + '.agents/sessions/readiness/findings/services.md', + snapshot_id: 'snapshot-1', + }, + }, + }, + ], + }, + ], + }, + toolResult: [], + } as any) + + expect(mismatch.value).toMatchObject({ toolName: 'add_message' }) + }) + + test('harvests over an error-only output and preserves the recorded error', () => { + const agent = createGeneralAgent({ model: 'opus' }) + const generator = agent.handleSteps!({ + prompt: 'Explain how the runtime bounds child output', + params: {}, + } as any) + + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + + // run-programmatic-step's failure path stamps + // `agentState.output = { ...output, error }`, so the output is defined but + // carries no summary. structured_output requires summary, so the parent + // would otherwise receive a summary-less object and no harvested text. + const harvest = generator.next({ + stepsComplete: true, + agentState: { + output: { + error: + 'Error executing handleSteps for agent general-agent: read_files failed', + }, + messageHistory: [ + { + role: 'assistant', + content: [{ type: 'text', text: 'Partial answer before failure.' }], + }, + ], + }, + toolResult: [], + } as any) + + expect(harvest.done).toBe(false) + expect(harvest.value).toEqual({ + toolName: 'set_output', + input: { + summary: 'Partial answer before failure.', + harvestedFromFallback: true, + error: + 'Error executing handleSteps for agent general-agent: read_files failed', + }, + includeToolCall: false, + }) + expect(generator.next({ toolResult: [] } as any).done).toBe(true) + }) + + test('harvests over an error-only output on the step-cap exit too', () => { + const agent = createGeneralAgent({ model: 'opus' }) + const generator = agent.handleSteps!({ + prompt: 'Explain how the runtime bounds child output', + params: {}, + } as any) + + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + + const harvest = generator.next({ + stepsComplete: false, + hitStepCap: true, + agentState: { + output: { + error: 'Error executing handleSteps for agent general-agent: boom', + }, + messageHistory: [ + { + role: 'assistant', + content: [{ type: 'text', text: 'Partial answer at the cap.' }], + }, + ], + }, + toolResult: [], + } as any) + + expect(harvest.value).toEqual({ + toolName: 'set_output', + input: { + summary: 'Partial answer at the cap.', + harvestedFromFallback: true, + error: 'Error executing handleSteps for agent general-agent: boom', + }, + includeToolCall: false, + }) + expect(generator.next({ toolResult: [] } as any).done).toBe(true) + }) + + test('harvests when the output object has a blank or non-string summary', () => { + const agent = createGeneralAgent({ model: 'opus' }) + + // Only a non-empty string summary is a real answer; a blank or wrong-typed + // one would reach the parent as no answer at all. + for (const output of [ + { summary: ' ' }, + { summary: 42 }, + { artifacts: ['a.md'] }, + 'not an object', + ]) { + const generator = agent.handleSteps!({ + prompt: 'Explain how the runtime bounds child output', + params: {}, + } as any) + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + + const harvest = generator.next({ + stepsComplete: true, + agentState: { + output, + messageHistory: [ + { + role: 'assistant', + content: [{ type: 'text', text: 'Recovered answer.' }], + }, + ], + }, + toolResult: [], + } as any) + + expect(harvest.value).toEqual({ + toolName: 'set_output', + input: { + summary: 'Recovered answer.', + harvestedFromFallback: true, + }, + includeToolCall: false, + }) + expect(generator.next({ toolResult: [] } as any).done).toBe(true) + } + }) + + test('declares the harvest error field so set_output accepts it', () => { + const agent = createGeneralAgent({ model: 'opus' }) + + // The harvest emits `error` alongside `summary`, so it must be declared + // exactly like harvestedFromFallback or set_output would reject the value. + expect(agent.outputSchema?.properties).toHaveProperty('error') + expect(agent.outputSchema?.properties?.error).toMatchObject({ + type: 'string', + }) + expect(agent.outputSchema?.required).not.toContain('error') + }) + + test('declares the no-answer marker and omits it from a real-text harvest', () => { + const agent = createGeneralAgent({ model: 'opus' }) + + // The empty-harvest exits emit `noHarvestedAnswer`, so it must be declared + // exactly like harvestedFromFallback or set_output would reject the value. + expect(agent.outputSchema?.properties).toHaveProperty('noHarvestedAnswer') + expect(agent.outputSchema?.properties?.noHarvestedAnswer).toMatchObject({ + type: 'boolean', + }) + expect(agent.outputSchema?.required).not.toContain('noHarvestedAnswer') + + // A harvest that recovered real answer text is a genuine completion, so it + // must NOT carry the marker — that absence is what keeps the runtime + // crediting it instead of emitting the retryable task_completed error. + const generator = agent.handleSteps!({ + prompt: 'Explain how the runtime bounds child output', + params: {}, + } as any) + + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + + const harvest = generator.next({ + stepsComplete: true, + agentState: { + output: undefined, + messageHistory: [ + { + role: 'assistant', + content: [{ type: 'text', text: 'Recovered real answer.' }], + }, + ], + }, + toolResult: [], + } as any) + + expect((harvest.value as any).input).toEqual({ + summary: 'Recovered real answer.', + harvestedFromFallback: true, + }) + expect(generator.next({ toolResult: [] } as any).done).toBe(true) }) }) diff --git a/agents/__tests__/quality-prompt-snapshot.test.ts b/agents/__tests__/quality-prompt-snapshot.test.ts index eaad56b4e2..8a37dd4a4a 100644 --- a/agents/__tests__/quality-prompt-snapshot.test.ts +++ b/agents/__tests__/quality-prompt-snapshot.test.ts @@ -338,28 +338,24 @@ describe('shared craftsmanship prompt sections', () => { expect(qualitySection).toContain('Don\'t type cast as "any"') }) - test('base2 system prompt prefers direct code_search and multi-query code-searcher', () => { - // Root content-search tools are granted; the prompt must prefer direct - // code_search for single-pattern search and code-searcher for multi-query - // batching. Guard the semantic content without freezing the exact wording. + test('base2 system prompt routes content search to the code_search tool', () => { + // Root content search is the code_search tool only; the code-searcher + // agent was removed, so the prompt must not name it anywhere. Guard the + // semantic content without freezing the exact wording. const base2 = createBase2('default') - expect(base2.systemPrompt).toContain('code-searcher') expect(base2.systemPrompt).toContain('code_search') - expect(base2.systemPrompt).toContain('Prefer direct') - expect(base2.systemPrompt).toContain('multi-query') + expect(base2.systemPrompt).not.toContain('code-searcher') expect(base2.systemPrompt).not.toContain('not granted to you as root') }) - test('base2 system prompt names required spawn params for code-searcher and basher', () => { - // Regression guard for observed spawn failures: code-searcher requires - // params.searchQueries and basher requires params.command. The prompt - // must name both required keys so the orchestrator supplies them in - // params instead of relying on the prose prompt and hitting a spawn - // rejection. + test('base2 system prompt names the required basher spawn param', () => { + // Regression guard for an observed spawn failure: basher requires + // params.command. The prompt must name that required key so the + // orchestrator supplies it in params instead of relying on the prose + // prompt and hitting a spawn rejection. const base2 = createBase2('default') - expect(base2.systemPrompt).toContain('params.searchQueries') expect(base2.systemPrompt).toContain('params.command') }) }) diff --git a/agents/base2/base-deep.ts b/agents/base2/base-deep.ts index ac217e7cd4..20536514c6 100644 --- a/agents/base2/base-deep.ts +++ b/agents/base2/base-deep.ts @@ -42,9 +42,9 @@ function buildDeepSystemPrompt( Use the spawn_agents tool to spawn specialized agents to help you complete the user's request. - **Spawn multiple agents in parallel:** This increases the speed of your response **and** allows you to be more comprehensive by spawning more total agents to synthesize the best response. Keep simple tasks simple; do not spawn agents when a direct answer or tiny edit is enough. -- **Task-scope classification:** Before editing, classify the task as tiny, focused, multi-file, cross-subsystem, or unknown surface. Tiny tasks require only the directly relevant read; focused tasks require reading the target file plus nearby tests/callers; multi-file tasks require search plus representative reads; cross-subsystem or unknown-surface tasks require query_index/list_directory/glob plus parallel file-picker/code-searcher shards before editing. +- **Task-scope classification:** Before editing, classify the task as tiny, focused, multi-file, cross-subsystem, or unknown surface. Tiny tasks require only the directly relevant read; focused tasks require reading the target file plus nearby tests/callers; multi-file tasks require search plus representative reads; cross-subsystem or unknown-surface tasks require query_index/list_directory/glob plus parallel file-picker shards plus direct code_search before editing. - **Phase-triggered delegation:** Spawn agents deterministically at phase boundaries, not randomly: context agents during discovery, thinker after context for complex design choices, bashers for validation, debugger after repeated validation/runtime failures, reviewers after edits, and doc/test writers when docs or tests are part of the acceptance criteria. -- **Context breadth:** For unclear or cross-cutting tasks, gather broad context first: query_index early, spawn multiple file-picker/code-searcher agents from different angles, add web/docs researchers for external APIs, then verify candidates with read_files/read_outline/read_subtree before editing. For large files prefer read_files windows/around/symbol selectors over guess-shrink-retry ranges paging. For tiny obvious edits, read only the directly relevant files. +- **Context breadth:** For unclear or cross-cutting tasks, gather broad context first: query_index early, spawn multiple file-pickers from different angles and run code_search yourself, add web/docs researchers for external APIs, then verify candidates with read_files/read_outline/read_subtree before editing. For large files prefer read_files windows/around/symbol selectors over guess-shrink-retry ranges paging. For tiny obvious edits, read only the directly relevant files. - **Ask-user decisions:** Ask only after context gathering, and only when the answer materially changes scope, UX, risk, data loss, migration, deployment, or API/contract behavior. Require confirmation before destructive commands, public API/contract changes, dependency additions, schema/data migrations, release/publish/deploy actions, production-affecting scripts, and ambiguous product behavior. Do not ask obvious questions; if you are >80% confident or the decision is easily reversible, choose the most conservative implementation and proceed. - **Thinker delegation:** Spawn thinker only after enough context exists for complex architecture, design tradeoff, risk, debugging strategy, spec/plan critique, or repeated-failure reasoning. Do not use thinker as a substitute for reading files or for straightforward edits. - **Release/deployment flow:** Treat releases, deployments, publishing, migrations against shared environments, production-affecting scripts, git commits, and git pushes as high-impact actions. Do not run or ask subagents to run them unless the user explicitly requested that action in this task or confirms after you explain the exact command, target environment, and rollback/verification plan. When requested, follow the deterministic sequence: inspect worktree, fetch remote state/tags, decide rebase/merge with the user when non-fast-forward or conflicts appear, push, wait for CI/CD, trigger the release, verify artifact/tag/package publication, then sync and report local branch state. @@ -55,8 +55,8 @@ Use the spawn_agents tool to spawn specialized agents to help you complete the u - **Validation selection:** Validate every non-trivial or risky edit with the narrowest relevant typecheck/test/lint/build command or configured file-change hooks. Map changed paths to suites deterministically when possible: agents/base2/* -> agents typecheck plus prompt/gate tests or e2e subset when behavior changes; agents/* -> agents typecheck and relevant agent tests; packages/sdk/* -> SDK typecheck/tests; packages/agent-runtime/* -> runtime typecheck/tests; common/* -> common checks plus dependent package typechecks; cli/src/components/* or cli/src/hooks/* -> CLI typecheck plus CLI visual smoke; docs/prompt-only changes -> configured hooks or explicit skip reason. Skip validation only for docs/prompt-only changes, tiny low-risk edits, explicit no-validation modes, or when the user forbids it; state the skip reason. Validation failures/timeouts are blocking and must be repaired or explicitly scoped out. Green basher typechecks or \`run_targeted_validation\` are optional evidence only — never a substitute for the runtime hooks+reviewer gate. - **Reviewer selection:** Use the automated reviewer gate for edited code in default mode. Spawn code-reviewer manually only for user-requested extra review, advisory/pre-edit review, significant diffs outside the automated gate, or changed code whose risk warrants another perspective; spawn security-reviewer for auth, crypto, secrets, permissions, injection, sandboxing, path/process/network handling, supply-chain, or production-risk changes; spawn test-writer when behavior changes lack coverage; spawn debugger after repeated validation failure, runtime failure, or unclear crash behavior. Do not duplicate the same post-edit review manually. - **Validation/reviewer coordination:** It is fine to run validation bashers and reviewers in parallel only when the reviewer is asked for static code review that explicitly does not depend on validation output. Always wait for both. Treat the final decision as a join of both results: validation failure/timeout blocks completion even if review looks good, and reviewer \`BLOCKING:\` blocks completion even if validation passes. When the review needs validation results, run validation first and include the completed validation summary in the reviewer prompt. - - For broad codebase questions or tasks where relevant files are not already obvious, call query_index early yourself to get indexed file candidates, then verify the best candidates, matchedSnippets, and relatedFiles with read_files/read_subtree and/or spawn file-picker/code-searcher agents as needed. Use graph modes when useful: search for ranked discovery, explain for ranking rationale, neighbors to expand around a known file, path to connect two known files, and commands to find package scripts, CI workflows, task runners, and validation docs. Do not rely on query_index alone for correctness. - - Spawn context-gathering agents (file pickers, code-searcher, and web/docs researchers) before making edits when the relevant files, APIs, or commands are not already obvious. Use query_index, read_files, read_outline, read_subtree, list_directory, and glob directly for codebase inspection when available instead of shelling out to cat/ls/find/grep/git status. + - For broad codebase questions or tasks where relevant files are not already obvious, call query_index early yourself to get indexed file candidates, then verify the best candidates, matchedSnippets, and relatedFiles with read_files/read_subtree and/or spawn file-picker agents as needed. Use graph modes when useful: search for ranked discovery, explain for ranking rationale, neighbors to expand around a known file, path to connect two known files, and commands to find package scripts, CI workflows, task runners, and validation docs. Do not rely on query_index alone for correctness. + - Spawn context-gathering agents (file pickers and web/docs researchers) before making edits when the relevant files, APIs, or commands are not already obvious. Use query_index, read_files, read_outline, read_subtree, list_directory, and glob directly for codebase inspection when available instead of shelling out to cat/ls/find/grep/git status. - Spawn the thinker after gathering context for complex design, architecture, risk, or debugging strategy decisions. Use semantic agent names rather than model-specific variants. - Implement code changes through edit_transaction. Select rewrite_symbol, str_replace, replace_range, patch, structured, create, or write_file as transaction edit types rather than separate tool calls. - Spawn bashers for validation/test coverage after edits when validation is appropriate; if validation fails, repair the exact failure before broadening scope. @@ -90,7 +90,7 @@ For other questions, you can direct them to openbuff.dev, or especially openbuff [ You write planning todos covering phases 1-3 ] -[ Phase 1 — Codebase Context & Research: You spawn file-pickers, code-searchers, and researchers (web/docs) in parallel to find relevant files and research external libraries/APIs, then read the results to build understanding ] +[ Phase 1 — Codebase Context & Research: You spawn file-pickers and researchers (web/docs) in parallel to find relevant files and research external libraries/APIs, then read the results to build understanding ] [ Phase 2 — Spec: You draft an initial SPEC.md, then use ask_user iteratively to refine it, then run thinker critique loop until clean ] @@ -180,7 +180,7 @@ Update these as you complete each step during implementation. Before asking questions or writing any code, gather broad context about the relevant parts of the codebase and any external knowledge needed: 1. Call query_index early yourself for broad codebase questions or tasks where relevant files are not already obvious. Use it to get indexed file candidates, not as a substitute for verification. Use graph modes when useful: search for ranked discovery, explain for ranking rationale, neighbors to expand around a known file, path to connect two known files, and commands to find package scripts, CI workflows, task runners, and validation docs. -2. Spawn file-picker, code-searcher, and researcher (researcher-web / researcher-docs) agents IN PARALLEL to find all files relevant to the user's request and research any libraries, APIs, or technologies involved. Cast a wide net — spawn multiple file-pickers with different angles, multiple code-searcher queries, and researchers for any external docs or web resources that could inform the implementation. Prefer dedicated read/search tools over shell fallbacks for repository inspection. +2. Spawn file-picker and researcher (researcher-web / researcher-docs) agents IN PARALLEL to find all files relevant to the user's request and research any libraries, APIs, or technologies involved. Cast a wide net — spawn multiple file-pickers with different angles, run multiple code_search queries yourself, and spawn researchers for any external docs or web resources that could inform the implementation. Prefer dedicated read/search tools over shell fallbacks for repository inspection. 3. Read the relevant files returned by query_index and these agents using read_files. Also use read_subtree on key directories if you need to understand the structure. 4. This context will help you ask better questions in the next phase and avoid building the wrong thing. @@ -338,7 +338,7 @@ export function createBaseDeep(options?: { stepPrompt: `Workflow phases reminder (${noLearning ? 6 : 7} phases): **Planning todos** (write at start): Phase 1 → Phase 2 → Phase 3 -1. Context & Research — query_index + file-pickers + code-searchers + researchers in parallel, read results +1. Context & Research — query_index + code_search + file-pickers + researchers in parallel, read results 2. Spec — draft SPEC.md, ${noAskUser ? '' : 'iterative ask_user to refine (skip obvious Qs), open-ended final Q, '}thinker critique loop 3. Plan — write PLAN.md, thinker critique loop diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index d3cf798633..d90f9dad0a 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -50,7 +50,7 @@ const DEFAULT_PROGRESSIVE_PROMPT_DISCLOSURE: boolean = true * detection live in `common/src/util/guides.ts`). */ const broadAuditPointer = - 'Broad audit / many-file / coverage-sweep request → read_files `agents/guides/broad-audit.md` before sharding. If that guide is unavailable, still scope first: measure breadth, dispatch one file-picker/code-searcher pair per subsystem in bounded waves, and machine-check coverage before synthesizing — never a single codesearch.' + 'Broad audit / many-file / coverage-sweep request → read_files `agents/guides/broad-audit.md` before sharding. If that guide is unavailable, still scope first: measure breadth, dispatch one file-picker + one general-agent audit shard per subsystem in bounded waves, and machine-check coverage before synthesizing — never a single codesearch.' /** Finalize clause whose section body `GUIDE_POINTER_TABLE` pins. */ const BROAD_AUDIT_ROW_CLAUSE: BroadAuditFinalizeClause = 'proceed to implementation or the answer' @@ -305,7 +305,7 @@ export function createBase2( // All agents including the orchestrator (base2) are BYOK-routed via // openbuff.json (defaultModel / modes / agents) with no hardcoded fallback. - // Cheaper subagents (file-picker/code-searcher) and the orchestrator itself + // Cheaper subagents (file-picker) and the orchestrator itself // can be overridden via openbuff.json routing (agents.*.model / modes / // defaultModel) without code changes; when modelOverride is undefined the // `model` field is omitted and @@ -475,7 +475,6 @@ export function createBase2( // permission exemption. 'context-pruner', 'file-picker', - 'code-searcher', 'general-agent', 'researcher-web', 'researcher-docs', @@ -545,7 +544,7 @@ ${ ? '- **Live visual analysis:** Use browser-use only for read-only inspection of an already available URL. Do not start dev servers or request browser interactions in plan mode.' : '- **Live visual verification:** Visual verification extends beyond web apps. Image artifacts from 3D renders (e.g. Blender frames), image/video exports, generated diagrams, and charts must be inspected with read_image, not inferred from text logs alone. The workflow is: render/export -> wait for the background job (check_job for agent readiness/exit; live job_update for users) -> read_image the emitted artifacts -> assess the result -> make a targeted edit -> re-render. check_job/check_background_agent/read_logs are only the agent-side bridge to artifact inspection — do not poll solely for user progress, and do not re-poll a finished or unchanging job indefinitely. After 2-3 unmatched polls that produce no new actionable artifact or progress, proceed with independent work, cancel/retry with a targeted edit, or ask the user. For web app visual checks specifically, start any long-running dev server through a BACKGROUND basher (finite commands stay SYNC), keep its returned jobId, use check_job to wait for readiness, then spawn browser-use for screenshots/navigation/interaction.' } -- **Prefer dedicated harness tools over shell fallbacks:** Repository status is injected automatically by the runtime; do not spawn basher merely to run git status. Use read_files/read_outline/read_subtree/glob/list_directory/query_index for file and codebase inspection instead of shelling out to cat/ls/find/grep. Prefer direct \`code_search\` for single-pattern content search (do not basher grep). Spawn \`code-searcher\` for multi-query batch search with \`params.searchQueries\`. Tiered read policy: small files (≤~400 lines) use read_files paths or ranges 1..totalLines for Tier1 whole-file auth (complete:true → reusable cap.v3); large/targeted blocks use read_files windows/around/symbol for Tier2 scoped caps (must be complete:true to mint). After successful edit_transaction, compress body to path/pointer but retain whole-file postEditCapabilities verbatim. Don't force windows for small files. Use basher for commands that do not have a dedicated tool, such as tests, builds, package scripts, and one-off project CLIs. Never embed a multi-KB file body or heredoc (\`<<'EOF' ... EOF\`) inside \`basher.params.command\`; the transport truncates large payloads and the JSON normalizer intentionally fails closed on truncated input. Author files with \`write_file\`/\`edit_transaction\` and run them via a short basher command instead. When you spawn an agent, pass its required params or the spawn fails: code-searcher needs \`params.searchQueries\` (an array of { pattern } objects) and basher needs \`params.command\` (a shell string); put these in \`params\`, not only in the prose prompt. Correct spawn_agents shape: { "agents": [{ "agent_type": "code-searcher", "prompt": "...", "params": { "searchQueries": [{ "pattern": "..." }] } }] } — prompt and params go INSIDE each agent entry, never as siblings of agents, and agents is a real array (never a JSON string). +- **Prefer dedicated harness tools over shell fallbacks:** Repository status is injected automatically by the runtime; do not spawn basher merely to run git status. Use read_files/read_outline/read_subtree/glob/list_directory/query_index for file and codebase inspection instead of shelling out to cat/ls/find/grep. Use \`code_search\` for ripgrep-style content search (do not basher grep); for several patterns, issue one \`code_search\` call per pattern — independent calls can go in the same message. Tiered read policy: small files (≤~400 lines) use read_files paths or ranges 1..totalLines for Tier1 whole-file auth (complete:true → reusable cap.v3); large/targeted blocks use read_files windows/around/symbol for Tier2 scoped caps (must be complete:true to mint). After successful edit_transaction, compress body to path/pointer but retain whole-file postEditCapabilities verbatim. Don't force windows for small files. Use basher for commands that do not have a dedicated tool, such as tests, builds, package scripts, and one-off project CLIs. Never embed a multi-KB file body or heredoc (\`<<'EOF' ... EOF\`) inside \`basher.params.command\`; the transport truncates large payloads and the JSON normalizer intentionally fails closed on truncated input. Author files with \`write_file\`/\`edit_transaction\` and run them via a short basher command instead. When you spawn an agent, pass its required params or the spawn fails: basher needs \`params.command\` (a shell string); put these in \`params\`, not only in the prose prompt. Correct spawn_agents shape: { "agents": [{ "agent_type": "basher", "prompt": "...", "params": { "command": "..." } }] } — prompt and params go INSIDE each agent entry, never as siblings of agents, and agents is a real array (never a JSON string). # Code Editing Mandates @@ -555,7 +554,7 @@ ${ - **Idiomatic Changes:** When editing, understand the local context (imports, functions/classes) to ensure your changes integrate naturally and idiomatically. - **Simplicity & Minimalism:** You should make as few changes as possible to the codebase to address the user's request. Only do what the user has asked for and no more. When modifying existing code, assume every line of code has a purpose and is there for a reason. Do not change the behavior of code except in the most minimal way to accomplish the user's request. - **Code Reuse:** Always reuse helper functions, components, classes, etc., whenever possible! Don't reimplement what already exists elsewhere in the codebase. -- **Refactoring Awareness:** Whenever you modify an exported symbol like a function or class or variable, you should find and update all the references to it appropriately by spawning a code-searcher agent. +- **Refactoring Awareness:** Whenever you modify an exported symbol like a function or class or variable, you should find and update all the references to it appropriately using code_search. - **Testing:** If you create a unit test, you should run it to see if it passes, and fix it if it doesn't. - **Package Management:** When adding dependencies, use the package manager identified from workspace evidence rather than editing manifests or lockfiles with guessed versions. Read only the discovered relevant manifest; do not probe unrelated ecosystem filenames. Do not install packages globally unless explicitly asked. - **Code Hygiene:** Make sure to leave things in a good state: @@ -601,7 +600,7 @@ Use the spawn_agents tool to spawn specialized agents to help you complete the u ? 'Spawn agents deterministically at phase boundaries, not randomly: context agents during discovery, thinker after context for complex design choices, editor for non-trivial implementation, bashers for validation, debugger after repeated validation/runtime failures, reviewers after edits, and doc/test writers when docs or tests are part of the acceptance criteria.' : 'Spawn agents deterministically at phase boundaries, not randomly: context agents during discovery, implement via edit_transaction, and spawn bashers, debugger, and reviewers as appropriate. Spawn doc/test writers when docs or tests are part of the acceptance criteria.' } -- **Context breadth:** For unclear or cross-cutting tasks, call query_index early yourself and deduplicate its relatedFiles/matchedSnippets. Spawn bounded, non-overlapping file-picker/code-searcher waves for explicit coverage gaps, joining each wave before deciding whether another is needed. Add web/docs researchers only for external APIs, then verify candidates with read_files/read_outline/read_subtree before editing. For large files prefer read_files windows/around/symbol selectors over guess-shrink-retry ranges paging. For tiny obvious edits, read only the directly relevant files. +- **Context breadth:** For unclear or cross-cutting tasks, call query_index early yourself and deduplicate its relatedFiles/matchedSnippets. Spawn bounded, non-overlapping file-picker waves (paired with general-agent shards when analysis is required) for explicit coverage gaps, joining each wave before deciding whether another is needed. Add web/docs researchers only for external APIs, then verify candidates with read_files/read_outline/read_subtree before editing. For large files prefer read_files windows/around/symbol selectors over guess-shrink-retry ranges paging. For tiny obvious edits, read only the directly relevant files. - **Ask-user decisions:** Ask only after context gathering, and only when the answer materially changes scope, UX, risk, data loss, migration, deployment, or API/contract behavior. Require confirmation before destructive commands, public API/contract changes, dependency additions, schema/data migrations, release/publish/deploy actions, production-affecting scripts, and ambiguous product behavior. Do not ask obvious questions; if you are >80% confident or the decision is easily reversible, choose the most conservative implementation and proceed. ${ isDefault && !planOnly @@ -623,9 +622,9 @@ ${ - **Reviewer selection:** Use the automated reviewer gate for edited code in default mode. Spawn code-reviewer manually only for user-requested extra review, advisory/pre-edit review, significant diffs outside the automated gate, or changed code whose risk warrants another perspective; spawn security-reviewer for auth, crypto, secrets, permissions, injection, sandboxing, path/process/network handling, supply-chain, or production-risk changes;${planOnly ? '' : ' spawn test-writer when behavior changes lack coverage;'} spawn debugger after repeated validation failure, runtime failure, or unclear crash behavior. Do not duplicate the same post-edit review manually. - **Validation/reviewer coordination:** It is fine to run validation bashers and reviewers in parallel only when the reviewer is asked for static code review that explicitly does not depend on validation output. Always wait for both. Treat the final decision as a join of both results: validation failure/timeout blocks completion even if review looks good, and reviewer \`BLOCKING:\` blocks completion even if validation passes. When the review needs validation results, run validation first and include the completed validation summary in the reviewer prompt. ${buildArray( - "- For broad codebase questions or tasks where relevant files are not already obvious, call query_index early yourself to get indexed file candidates, then verify the best candidates with read_files/read_subtree and/or spawn file-picker/code-searcher agents as needed. Use mode: 'commands' for project scripts, CI, task runners, or validation-suite command discovery. Do not rely on query_index alone for correctness.", + "- For broad codebase questions or tasks where relevant files are not already obvious, call query_index early yourself to get indexed file candidates, then verify the best candidates with read_files/read_subtree and/or spawn file-picker agents as needed. Use mode: 'commands' for project scripts, CI, task runners, or validation-suite command discovery. Do not rely on query_index alone for correctness.", "- For blast-radius analysis before editing an exported symbol, use mode: 'references' with from or to set to the seed file path — it returns files that import or call into that seed.", - '- Spawn context-gathering agents (file pickers, code searchers, and web/docs researchers) before making edits when the relevant files, APIs, or commands are not already obvious. Use query_index, list_directory, and glob directly for searching and exploring the codebase.', + '- Spawn context-gathering agents (file pickers and web/docs researchers) before making edits when the relevant files, APIs, or commands are not already obvious. Use query_index, list_directory, and glob directly for searching and exploring the codebase.', isDefault && !planOnly && '- Spawn the editor agent after discovery for non-trivial source changes. Keep the handoff self-contained and implementation-only because the editor does not inherit parent conversation history.', @@ -684,11 +683,11 @@ ${buildArray( please implement [a complex new feature] -[ You spawn 3 file-pickers, 2 code-searchers, and a docs researcher in parallel to find relevant files and do research online. You use the list_directory and glob tools directly to search the codebase. ] +[ You spawn 3 file-pickers and a docs researcher in parallel to find relevant files and do research online. You use the code_search, list_directory and glob tools directly to search the codebase. ] [ You read a few of the relevant files using the read_files tool in two separate tool calls ] -[ You spawn another file-picker and code-searcher to find more relevant files, and use glob tools ] +[ You spawn another file-picker to find more relevant files, and use code_search and glob tools ] [ You read a few other relevant files using the read_files tool ]${ !noAskUser @@ -10645,7 +10644,7 @@ function hashGateSnapshotDetails(details: string): string { }, } } -const EXPLORE_PROMPT = `- Iteratively gather codebase context as needed. For broad codebase questions or tasks where relevant files are not already obvious, call query_index early yourself and deduplicate its candidates by path, score, reason, and kind. Use mode: 'explain' when you need ranking rationale, mode: 'neighbors' to expand around a known file, mode: 'path' to connect two known files, mode: 'references' for blast-radius analysis (files that import or call into a seed file, using from or to), and mode: 'commands' to find package scripts, CI workflows, task runners, and validation docs. Spawn bounded parallel discovery waves for explicit domains the index result did not cover; give each file-picker/code-searcher a non-overlapping question, join the wave, and launch another when inventory or coverage evidence still has gaps. There is no fixed total-agent limit. Verify selected files with read_files/read_subtree. Use list_directory and glob only when structural/path evidence is missing, and do not substitute basher for git status or file discovery. Use read_subtree for a specific subsystem. For a large file, prefer read_files windows/around/symbol selectors over guess-shrink-retry ranges paging; use read_outline then read_files ranges only for an exact arbitrary line range. Read all relevant files before editing.` +const EXPLORE_PROMPT = `- Iteratively gather codebase context as needed. For broad codebase questions or tasks where relevant files are not already obvious, call query_index early yourself and deduplicate its candidates by path, score, reason, and kind. Use mode: 'explain' when you need ranking rationale, mode: 'neighbors' to expand around a known file, mode: 'path' to connect two known files, mode: 'references' for blast-radius analysis (files that import or call into a seed file, using from or to), and mode: 'commands' to find package scripts, CI workflows, task runners, and validation docs. Spawn bounded parallel discovery waves for explicit domains the index result did not cover; give each file-picker a non-overlapping question, join the wave, and launch another when inventory or coverage evidence still has gaps. There is no fixed total-agent limit. Verify selected files with read_files/read_subtree. Use list_directory and glob only when structural/path evidence is missing, and do not substitute basher for git status or file discovery. Use read_subtree for a specific subsystem. For a large file, prefer read_files windows/around/symbol selectors over guess-shrink-retry ranges paging; use read_outline then read_files ranges only for an exact arbitrary line range. Read all relevant files before editing.` function buildImplementationInstructionsPrompt({ isFast, diff --git a/agents/context-pruner.ts b/agents/context-pruner.ts index 3c78d2b246..37c91521eb 100644 --- a/agents/context-pruner.ts +++ b/agents/context-pruner.ts @@ -216,7 +216,6 @@ const definition: AgentDefinition = { 'read_subtree', 'read_image', 'code_search', - 'code_searcher', 'query_index', 'glob', 'list_directory', diff --git a/agents/file-explorer/code-searcher.ts b/agents/file-explorer/code-searcher.ts deleted file mode 100644 index 29e164c2c5..0000000000 --- a/agents/file-explorer/code-searcher.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { publisher } from '../constants' - -import type { SecretAgentDefinition } from '../types/secret-agent-definition' -import type { JSONValue } from '../types/util-types' - -interface SearchQuery { - pattern: string - flags?: string | string[] - cwd?: string - paths?: string[] - maxResults?: number -} - -const paramsSchema = { - type: 'object' as const, - properties: { - searchQueries: { - type: 'array' as const, - items: { - type: 'object' as const, - properties: { - pattern: { - type: 'string' as const, - description: 'The pattern to search for', - }, - flags: { - anyOf: [ - { type: 'string' as const }, - { type: 'array' as const, items: { type: 'string' as const } }, - ], - description: `Optional safe ripgrep flags as a string or argv token array. 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. Examples: "-g *.ts -A 3" or ["-g", "*.ts", "-A", "3"]. Line numbers are automatic; redundant -n/--line-number inputs are ignored. Do not quote the entire expression inside a JSON string. Output-shape or dangerous flags (e.g. -c/--count, --count-matches, -l, -v/--invert-match, --exec, -r/--replace, -z/--null) are rejected.`, - }, - cwd: { - type: 'string' as const, - description: - 'Optional working directory or single file to search within, relative to the project root or absolute (absolute may be outside the project). A directory scopes the search under that path; a file scopes the search to that file only. Defaults to searching the entire project', - }, - paths: { - type: 'array' as const, - items: { type: 'string' as const }, - description: - 'Optional list of file and/or directory paths to search (relative to project root or absolute). When set, searches only these targets instead of the whole tree (no automatic hidden-dir expansion)', - }, - maxResults: { - type: 'number' as const, - description: - 'Maximum number of results to return per file. Defaults to 15. There is also a global limit of 250 results across all files', - }, - }, - required: ['pattern'], - }, - description: 'Array of code search queries to execute', - }, - }, - required: ['searchQueries'], -} - -const codeSearcher: SecretAgentDefinition = { - id: 'code-searcher', - displayName: 'Code Searcher', - spawnerPrompt: `Mechanically runs multiple code search queries (using ripgrep line-oriented search) and returns up to 250 results across all source files, showing each line that matches the search pattern. Excludes git-ignored files. You MUST pass searchQueries in params. Example input: { "params": { "searchQueries": [{ "pattern": "createUser", "flags": "-g *.ts" }, { "pattern": "deleteUser", "flags": "-g *.ts" }, { "pattern": "UserSchema", "maxResults": 5 }] } }`, - publisher, - includeMessageHistory: false, - toolNames: ['code_search'], - programmaticToolNames: ['set_output'], - spawnableAgents: [], - inputSchema: { - params: paramsSchema, - }, - outputMode: 'structured_output', - handleSteps: function* ({ params }) { - /** Short, safe description of an arbitrary value for diagnostic messages. */ - function describeValue(value: unknown): string { - if (value === null) return 'null' - if (value === undefined) return 'undefined' - if (Array.isArray(value)) return `an array of length ${value.length}` - return `a value of type ${typeof value}` - } - - /** - * A code_search JSON result counts as "non-empty" when it actually surfaced - * matches. ripgrep returns "Found 0 matches" stdout (or an errorMessage) - * when nothing matched, so we treat those as not-a-match for summary purposes. - */ - function isNonEmptyResult(value: JSONValue): boolean { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false - } - const record = value as Record - if (typeof record.errorMessage === 'string') return false - const stdout = record.stdout - if (typeof stdout !== 'string') return false - return stdout.trim().length > 0 && !stdout.includes('Found 0 matches') - } - - const rawQueries = params?.searchQueries - - // Guard against malformed invocations that previously produced silent empty - // results. If searchQueries is missing or not an array, report exactly what - // was received and how to call this agent correctly instead of returning 0 - // results with an empty message. - if (!Array.isArray(rawQueries)) { - yield { - toolName: 'set_output', - input: { - message: - `No search ran: "searchQueries" must be an array passed in params, but received ${describeValue( - rawQueries, - )}. ` + - `Call this agent like: { "params": { "searchQueries": [{ "pattern": "createUser", "flags": "-g *.ts" }] } }.`, - results: [], - }, - includeToolCall: false, - } - return - } - - // Partition into valid queries (non-empty string pattern) and invalid ones - // so we can run the good queries and still surface clear feedback about the - // bad ones rather than silently dropping them. - const validQueries: SearchQuery[] = [] - const invalidQueries: string[] = [] - rawQueries.forEach((query, index) => { - if ( - query && - typeof query === 'object' && - typeof (query as SearchQuery).pattern === 'string' && - (query as SearchQuery).pattern.trim().length > 0 - ) { - validQueries.push(query as SearchQuery) - } else { - invalidQueries.push( - `query[${index}] is missing a non-empty string "pattern" (received ${describeValue( - query, - )})`, - ) - } - }) - - if (validQueries.length === 0) { - yield { - toolName: 'set_output', - input: { - message: - `No search ran: none of the ${rawQueries.length} provided ` + - `quer${rawQueries.length === 1 ? 'y' : 'ies'} had a valid "pattern". ` + - (invalidQueries.length > 0 - ? `${invalidQueries.join('; ')}. ` - : '') + - `Each query needs a non-empty string "pattern".`, - results: [], - }, - includeToolCall: false, - } - return - } - - const toolResults: JSONValue[] = [] - let matchedQueryCount = 0 - let rejectedQueryCount = invalidQueries.length - for (const query of validQueries) { - const { toolResult } = yield { - toolName: 'code_search', - input: { - pattern: query.pattern, - flags: query.flags, - cwd: query.cwd, - paths: query.paths, - maxResults: query.maxResults, - }, - } - if (toolResult) { - const jsonValues = toolResult - .filter((result) => result.type === 'json') - .map((result) => result.value) - toolResults.push(...jsonValues) - if ( - jsonValues.some( - (value) => - value !== null && - typeof value === 'object' && - !Array.isArray(value) && - typeof (value as Record).errorMessage === - 'string', - ) - ) { - rejectedQueryCount++ - } - if (jsonValues.some(isNonEmptyResult)) { - matchedQueryCount++ - } - } - } - - // Build a concise summary so an empty result set is always explained (no - // matches vs. an error like a malformed ripgrep flag), rather than handing - // back results with an empty message. - const summaryParts: string[] = [ - `Attempted ${rawQueries.length} quer${ - rawQueries.length === 1 ? 'y' : 'ies' - }; executed ${validQueries.length}; rejected ${rejectedQueryCount}; ${matchedQueryCount} returned matches.`, - ] - if (invalidQueries.length > 0) { - summaryParts.push( - `Skipped ${invalidQueries.length} invalid quer${ - invalidQueries.length === 1 ? 'y' : 'ies' - }: ${invalidQueries.join('; ')}.`, - ) - } - if (matchedQueryCount === 0) { - summaryParts.push( - 'No matches found. Check that the pattern is valid Rust-style regex and that any flags (e.g. -g globs, cwd) are correct.', - ) - } - - /** - * Heuristic ≤200-token digest of raw ripgrep output so the orchestrator - * can scan result themes without re-reading the full stdout. Deterministic - * (no LLM call) because this agent is a pure tool-execution agent with no - * prompt tool available in its sandboxed handleSteps. - * - * Format: " matches across files. Top files: ... Symbols: ..." - * Bounded: top 5 files, top 8 symbols. Stays well under ~200 tokens. - */ - function buildDigest(results: JSONValue[]): string { - type Match = { file: string; content: string } - const matches: Match[] = [] - let currentFile = '' - for (const result of results) { - if (!result || typeof result !== 'object' || Array.isArray(result)) { - continue - } - const record = result as Record - if (typeof record.errorMessage === 'string') continue - const stdout = record.stdout - if (typeof stdout !== 'string') continue - for (const line of stdout.split('\n')) { - // File header lines look like "./path/to/file.ts:" or "file.ts:" - // (end with ':', no leading whitespace). The leading "Found N matches" - // summary line does not end with ':' so it is not misclassified. - if (line.length > 0 && !line.startsWith(' ') && line.endsWith(':')) { - currentFile = line.slice(0, -1).replace(/^\.\//, '') - continue - } - // Match lines look like " Line N: ". - const m = line.match(/^\s+Line \d+:\s*(.*)$/) - if (m && currentFile) { - matches.push({ file: currentFile, content: m[1] }) - } - } - } - if (matches.length === 0) return '' - // Top files by match count. - const fileCounts = new Map() - for (const mt of matches) { - fileCounts.set(mt.file, (fileCounts.get(mt.file) ?? 0) + 1) - } - const topFiles = [...fileCounts.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) - .map(([f, c]) => `${f} (${c})`) - .join(', ') - // Candidate symbols: camelCase / PascalCase / snake_case identifiers - // pulled from matched line content, deduped by frequency. - const symbolCounts = new Map() - const symbolRe = /\b[A-Za-z_][A-Za-z0-9_]{2,}\b/g - for (const mt of matches) { - let sm: RegExpExecArray | null - while ((sm = symbolRe.exec(mt.content)) !== null) { - const tok = sm[0] - // Skip common noise tokens. - if ( - /^(return|const|let|var|function|import|export|from|type|interface|class|new|if|else|for|while|async|await|true|false|null|undefined|this|self)$/.test( - tok, - ) - ) { - continue - } - symbolCounts.set(tok, (symbolCounts.get(tok) ?? 0) + 1) - } - } - const topSymbols = [...symbolCounts.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, 8) - .map(([s]) => s) - .join(', ') - const parts = [ - `${matches.length} matches across ${fileCounts.size} file${ - fileCounts.size === 1 ? '' : 's' - }.`, - `Top files: ${topFiles}.`, - ] - if (topSymbols) parts.push(`Symbols: ${topSymbols}.`) - return parts.join(' ') - } - - const digest = buildDigest(toolResults) - - yield { - toolName: 'set_output', - input: { - message: summaryParts.join(' '), - digest, - results: toolResults, - }, - includeToolCall: false, - } - }, -} - -export default codeSearcher diff --git a/agents/general-agent/general-agent.ts b/agents/general-agent/general-agent.ts index 823f62bc6d..bb9620dc8f 100644 --- a/agents/general-agent/general-agent.ts +++ b/agents/general-agent/general-agent.ts @@ -61,17 +61,56 @@ export const createGeneralAgent = (options: { snapshotId: { type: 'string', description: - 'Exact structural snapshot id to bind into the audit shard receipt. Required whenever sessionSlug and shardId are present: when it is absent or blank, the shard stays unbound-by-snapshot and must fail closed, with write_audit_findings rejected.', + 'Exact structural snapshot id to bind into the audit shard receipt. When it is absent or blank the findings artifact is still written, but the result carries no snapshot-bound structuralReceipt, so the shard cannot claim snapshot-bound coverage.', }, }, }, }, - outputMode: 'last_message', + outputMode: 'structured_output', + outputSchema: { + type: 'object', + properties: { + summary: { + type: 'string', + description: + 'The requested answer, or for an audit shard the compact write_audit_findings receipt summary. This is the field the parent agent reads, so put the substance here rather than in prose outside the tool call.', + }, + artifacts: { + type: 'array', + items: { type: 'string' }, + description: + 'Project-relative paths this agent persisted (e.g. the findings artifact path).', + }, + coveredSubsystems: { type: 'array', items: { type: 'string' } }, + coveredFeatures: { type: 'array', items: { type: 'string' } }, + unresolved: { + type: 'array', + items: { type: 'string' }, + description: + 'Anything the shard could not verify, so the parent does not assume coverage.', + }, + harvestedFromFallback: { + type: 'boolean', + description: + 'Set by the runtime fallback when the answer had to be harvested from assistant text instead of an explicit set_output call.', + }, + noHarvestedAnswer: { + type: 'boolean', + description: + 'Set by the runtime fallback when the harvest recovered no answer text at all, so summary is only a placeholder. It marks the run as answerless, which is why such a harvest never stands in for an explicit completion.', + }, + error: { + type: 'string', + description: + 'Set by the runtime fallback when the agent state already recorded a step error, so the failure reaches the parent alongside the harvested summary instead of being swallowed.', + }, + }, + required: ['summary'], + }, spawnableAgents: buildArray( 'researcher-web', 'researcher-docs', !isGpt5 && 'file-picker', - 'code-searcher', 'context-pruner', ), toolNames: [ @@ -79,9 +118,13 @@ export const createGeneralAgent = (options: { 'query_index', 'read_files', 'read_subtree', + 'read_outline', + 'glob', + 'list_directory', 'code_search', 'task_completed', 'write_audit_findings', + 'set_output', ], filesystemScope: { read: ['**/*'], @@ -91,12 +134,13 @@ export const createGeneralAgent = (options: { instructionsPrompt: buildArray( `Use the spawn_agents tool to spawn agents to help you complete the user request.`, - `For broad codebase questions or tasks where relevant files are not already obvious, call query_index early yourself to get indexed file candidates, then verify the best candidates with read_files/read_subtree and/or spawn file-picker/code-searcher agents as needed. Use query_index mode: 'explain' when you need ranking rationale, mode: 'neighbors' to expand around a known file, mode: 'path' to connect two known files, and mode: 'commands' to find package scripts, CI workflows, task runners, and validation docs. Do not rely on query_index alone for correctness.`, + `For broad codebase questions or tasks where relevant files are not already obvious, call query_index early yourself to get indexed file candidates, then verify the best candidates with read_files/read_subtree and/or spawn file-picker agents as needed. Use query_index mode: 'explain' when you need ranking rationale, mode: 'neighbors' to expand around a known file, mode: 'path' to connect two known files, and mode: 'commands' to find package scripts, CI workflows, task runners, and validation docs. Do not rely on query_index alone for correctness. Use \`glob\` to find files by path pattern, \`list_directory\` to inspect a directory's entries, and \`read_outline\` to get a file's structure before a full read.`, !isGpt5 && - `If indexed evidence leaves explicit coverage gaps, spawn bounded parallel waves of non-overlapping file-picker/code-searcher/researcher tasks. Join each wave before deciding whether more coverage is needed; do not restart the same discovery through multiple agent layers.`, - `File-picker and code-searcher are discovery-only helpers. Their results do not satisfy analysis, implementation-completeness, call-site, test-coverage, or dead-code claims. Read and verify the relevant source and test files yourself before synthesizing the requested answer.`, - `For ripgrep-style content search, prefer direct \`code_search\` for single-pattern work (pattern/flags/cwd/maxResults). Spawn code-searcher only for multi-query batch search, and pass required \`params.searchQueries\` (an array of { pattern } objects, e.g. { "params": { "searchQueries": [{ "pattern": "createUser", "flags": "-g *.ts" }] } }); put it in \`params\`, not only in the prose prompt.`, - `When params.sessionSlug and params.shardId are provided, this is a durable audit shard. params.snapshotId must be the exact inspect_codebase_structure snapshot; copy it into write_audit_findings.snapshotId. If params.snapshotId is absent or blank, the shard is unbound-by-snapshot and fails closed: do not call write_audit_findings (its snapshot-bound structural receipt cannot satisfy the completion gate); instead analyze the assigned files and return your findings inline. Analyze the assigned files, call write_audit_findings exactly once with structured findings and full subsystem/feature/file/domain coverage, then return only its compact artifact receipt, including structuralReceipt. Do not repeat findings in your final response.`, + `If indexed evidence leaves explicit coverage gaps, spawn bounded parallel waves of non-overlapping file-picker/researcher tasks. Join each wave before deciding whether more coverage is needed; do not restart the same discovery through multiple agent layers.`, + `File-picker is a discovery-only helper. Their results do not satisfy analysis, implementation-completeness, call-site, test-coverage, or dead-code claims. Read and verify the relevant source and test files yourself before synthesizing the requested answer.`, + `For ripgrep-style content search, call \`code_search\` directly (pattern/flags/cwd/maxResults). For several patterns, issue one \`code_search\` call per pattern rather than delegating the search.`, + `When params.sessionSlug and params.shardId are provided, this is a durable audit shard: analyze the assigned files and call write_audit_findings exactly once with structured findings and full subsystem/feature/file/domain coverage. That is required with or without params.snapshotId. When params.snapshotId is present it is the exact inspect_codebase_structure snapshot; copy it into write_audit_findings.snapshotId verbatim, which is what yields the snapshot-bound structuralReceipt the parent's coverage check consumes. When params.snapshotId is absent or blank, still write the artifact: the result then carries no structuralReceipt, so say so explicitly in your summary instead of claiming snapshot-bound coverage. If the write is rejected because the artifact already exists, distinguish the two collisions. When the rejection result carries the already-persisted marker together with the compact artifact receipt, the artifact on disk is byte-identical to this call's rendered findings, so this call's findings are the persisted ones: treat it as an idempotent success, report that existing artifact path in your summary, and do NOT write a second artifact under a suffixed shard id. When the rejection instead says the existing artifact's contents are not this call's findings, nothing from this call is persisted, so persist these findings under a distinct shard id to obtain a composable coverage receipt. Return only its compact artifact receipt and do not repeat findings in your final response.`, + `Finish by calling set_output with summary (plus artifacts, coveredSubsystems, coveredFeatures, and unresolved when they apply). The parent agent reads your structured output rather than your prose, and prose outside the tool call may be compacted away, so put the substance of the answer or the compact audit receipt in summary.`, `Do not stop after announcing a tool call or delegating discovery. In the same final response that contains the requested answer or compact audit receipt, call task_completed. Never call task_completed while required reads, synthesis, coverage, or audit artifact persistence remain unfinished.`, ).join('\n'), @@ -153,6 +197,106 @@ export const createGeneralAgent = (options: { | undefined let auditCompletionRetries = 0 let didRunPrunerOnce = false + // Local closures: this generator is serialized for sandbox execution, so + // they must not reference module-level bindings; the runtime notice tag + // literals are inlined here for the same reason. + const harvestedAnswerText = (messageHistory: unknown): string => { + if (!Array.isArray(messageHistory)) return '' + // Mirrors getLastAssistantTurnMessages: from the last assistant + // message, walk backwards while messages stay assistant so the whole + // trailing assistant turn is harvested, not only its last message. + let turnEnd = -1 + for (let index = messageHistory.length - 1; index >= 0; index--) { + const message = messageHistory[index] as + | { role?: unknown } + | undefined + if (message && message.role === 'assistant') { + turnEnd = index + break + } + } + if (turnEnd < 0) return '' + let turnStart = turnEnd + while (turnStart > 0) { + const previous = messageHistory[turnStart - 1] as + | { role?: unknown } + | undefined + if (!previous || previous.role !== 'assistant') break + turnStart-- + } + const messageTexts: string[] = [] + for (let index = turnStart; index <= turnEnd; index++) { + const message = messageHistory[index] as + | { content?: unknown; tags?: unknown } + | undefined + if (!message) continue + // A runtime terminal notice (step cap, tool-call error) is never the + // model's answer, so it must never be reported as one. + const tags = Array.isArray(message.tags) + ? (message.tags as unknown[]) + : [] + if ( + tags.includes('STEP_CAP_REACHED') || + tags.includes('TOOL_CALL_ERROR') + ) { + continue + } + const content = message.content + const text = Array.isArray(content) + ? content + .filter( + (part) => + part && + part.type === 'text' && + typeof part.text === 'string', + ) + .map((part) => part.text) + .join('') + : typeof content === 'string' + ? content + : '' + if (text) messageTexts.push(text) + } + return messageTexts + .join('\n') + .replace(/[\s\S]*?<\/think>/g, '') + .replace(/[\s\S]*$/, '') + .trim() + } + // structured_output: the parent reads the structured output, so every + // exit must report something non-empty rather than leaving value: null. + // The placeholder below is NOT an answer, so both exits pair it with an + // explicit noHarvestedAnswer marker: the runtime credits a harvest as + // explicit completion only when it recovered real answer text. + const harvestedSummary = ( + harvestedText: string, + atStepCap: boolean, + ): string => + harvestedText || + (atStepCap + ? 'No answer text was produced before this agent hit its step cap, so there is no harvested final answer to report.' + : 'No answer text was produced before this agent finished, so there is no harvested final answer to report.') + // The output is only a real answer when it carries a non-empty string + // summary, which is this agent's one required output field. Anything else + // — undefined, a non-object, or the `{ ...output, error }` object the + // programmatic-step failure path stamps after a handleSteps tool error — + // would reach the parent with no answer at all, so it is harvested. + const needsHarvestedAnswer = (output: unknown): boolean => { + if (!output || typeof output !== 'object' || Array.isArray(output)) { + return true + } + const summary = (output as { summary?: unknown }).summary + return typeof summary !== 'string' || summary.trim() === '' + } + // Harvesting over an error-only output must not swallow the failure, so + // the recorded error is carried into the emitted set_output. + const recordedOutputError = (output: unknown): string => { + if (!output || typeof output !== 'object' || Array.isArray(output)) { + return '' + } + const error = (output as { error?: unknown }).error + return typeof error === 'string' && error.trim() ? error : '' + } while (true) { const tokenCount = latestAgentStateForPruner?.contextTokenCount ?? 0 const windowTokens = latestAgentStateForPruner?.contextWindowTokens @@ -184,6 +328,7 @@ export const createGeneralAgent = (options: { hitStepCap?: boolean agentState?: { messageHistory?: unknown[] + output?: unknown contextTokenCount?: number contextWindowTokens?: number } @@ -192,7 +337,33 @@ export const createGeneralAgent = (options: { latestAgentStateForPruner = stepResult.agentState as typeof latestAgentStateForPruner } - if ((stepResult as { hitStepCap?: boolean }).hitStepCap) break + if ((stepResult as { hitStepCap?: boolean }).hitStepCap) { + // An answer written only as assistant text, or an output that only + // records a step error, would reach the parent without a summary, so + // harvest it before giving up. An explicit set_output that carries a + // real summary is never overwritten. + const cappedOutput = stepResult.agentState?.output + if (needsHarvestedAnswer(cappedOutput)) { + const harvestedText = harvestedAnswerText( + stepResult.agentState?.messageHistory, + ) + const recordedError = recordedOutputError(cappedOutput) + yield { + toolName: 'set_output', + input: { + summary: harvestedSummary(harvestedText, true), + harvestedFromFallback: true, + // Nothing was recovered, so the summary is only a placeholder: + // mark the run answerless instead of letting the harvest stand + // in for the explicit completion that never happened. + ...(harvestedText ? {} : { noHarvestedAnswer: true }), + ...(recordedError ? { error: recordedError } : {}), + }, + includeToolCall: false, + } + } + break + } if (!stepResult.stepsComplete) continue const sessionSlug = @@ -225,6 +396,29 @@ export const createGeneralAgent = (options: { } continue } + // Same harvest on the ordinary exit: a text-only answer, or an output + // that only records a step error, must still reach the parent as + // structured output with a usable summary. + const completedOutput = stepResult.agentState?.output + if (needsHarvestedAnswer(completedOutput)) { + const harvestedText = harvestedAnswerText( + stepResult.agentState?.messageHistory, + ) + const recordedError = recordedOutputError(completedOutput) + yield { + toolName: 'set_output', + input: { + summary: harvestedSummary(harvestedText, false), + harvestedFromFallback: true, + // Same answerless marker on the ordinary exit: an agent that + // finished without producing any answer text is a retryable + // partial for the parent, not a completed run. + ...(harvestedText ? {} : { noHarvestedAnswer: true }), + ...(recordedError ? { error: recordedError } : {}), + }, + includeToolCall: false, + } + } break } }, diff --git a/agents/guides/broad-audit.md b/agents/guides/broad-audit.md index a6acdf470e..6c8d547ee0 100644 --- a/agents/guides/broad-audit.md +++ b/agents/guides/broad-audit.md @@ -3,13 +3,13 @@ For broad, open-ended, or audit-style requests (for example: "check this codebase for any feature improvements", "audit the codebase for security/correctness/perf issues", "assess this codebase for how production ready it is on a feature, security and code level", "find all the places X is handled", "what can be improved in the agents/sdk/cli", or anything where the relevant surface is not already obvious), do NOT default to a single surface-level codesearch or one or two file reads. Instead, run a deliberate scope-then-shard flow: 1. **Assess scope and measure breadth.** The runtime starts cross-subsystem requests with `inspect_codebase_structure`; treat its snapshot-bound subsystem, entrypoint, route, command, public-API, test, generated-source, and language/framework capability inventory as authoritative for shard allocation. Supplement it with query_index only for semantic discovery. Count the distinct subsystems / packages / concerns the request spans. Pick the shard count from this adaptive rubric (breadth = number of distinct subsystems the request touches): - - **breadth 1–2 (focused):** one shard pair per subsystem (one file-picker + one code-searcher), plus a docs researcher if a major external library is involved. - - **breadth 3–5 (multi-subsystem audit):** at least one complete file-picker/code-searcher pair per subsystem. Dispatch the pairs in bounded waves when they exceed the per-call limit. - - **breadth 6+ (whole-codebase audit):** at least one complete file-picker/code-searcher pair per subsystem, plus one researcher-docs per major external library involved, dispatched in bounded waves. - The `file-picker` and `code-searcher` shards named above are DISCOVERY-ONLY: they return prose and file paths, not receipts, and cannot emit a `structuralReceipt`. Their output feeds the reasoning/audit shards (step 3), it is not passed to `evaluate_audit_coverage` directly. The wider the surface, the more shards. Each call must respect the advertised batch limit, but there is no fixed total-agent limit: join a wave, evaluate coverage, and launch another until the inventory is covered. Never default to a single codesearch for an audit-style request. + - **breadth 1–2 (focused):** one shard pair per subsystem (one file-picker for discovery + one general-agent audit shard for analysis), plus a docs researcher if a major external library is involved. + - **breadth 3–5 (multi-subsystem audit):** at least one complete file-picker + general-agent audit-shard pair per subsystem. Dispatch the pairs in bounded waves when they exceed the per-call limit. + - **breadth 6+ (whole-codebase audit):** at least one complete file-picker + general-agent audit-shard pair per subsystem, plus one researcher-docs per major external library involved, dispatched in bounded waves. + The `file-picker` shards named above are DISCOVERY-ONLY: they return prose and file paths, not receipts, and cannot emit a `structuralReceipt`. Their output feeds the paired `general-agent` audit shard (step 3), which is the shard that emits `structuralReceipt` via `write_audit_findings`; discovery output is not passed to `evaluate_audit_coverage` directly. The wider the surface, the more shards. Each call must respect the advertised batch limit, but there is no fixed total-agent limit: join a wave, evaluate coverage, and launch another until the inventory is covered. Never default to a single codesearch for an audit-style request. 2. **Check frontend presence and coverage.** If top-level dirs, routes, pages, app/, src/, components/, or framework config indicate a frontend exists, the audit must cover UI page wiring, routes, navigation, API integration, auth/error/loading states, accessibility, and responsiveness. If no frontend is present, explicitly mark frontend/UI coverage out-of-scope rather than silently omitting it. -3. **Shard by feature slices and structure.** Make vertical feature slices (entrypoint or UI/command → orchestrator/runtime → service/storage/provider → tests/docs/failure states) the primary reasoning shards. Add structural package shards and cross-cutting domain shards for security, compatibility, performance, accessibility, migration, and reliability. Attach the inventory's language/framework capability packet instead of selecting a language-specific agent. These reasoning/audit shards are `general-agent` shards invoked with the `write_audit_findings` tool (passing the `sessionSlug`, `shardId`, and `snapshotId`) — that tool is what emits each shard's `structuralReceipt`, and these are the receipts that feed `evaluate_audit_coverage`. The `file-picker`/`code-searcher` discovery shards from steps 1–2 are inputs to these audit shards: they hand over prose and paths, they do not produce receipts. Each shard must return the subsystem IDs and feature IDs it actually covered. -4. **Machine-check completeness before synthesis.** Run `inspect_feature_completeness` for every claimed or discovered user-visible feature, then `evaluate_audit_coverage` with the exact inventory snapshot, each audit shard's returned `structuralReceipt` (these come only from the `general-agent` + `write_audit_findings` audit shards of step 3, never from the discovery-only `file-picker`/`code-searcher` shards), each feature inspection's returned `coverageReceipt`, and explicit out-of-scope reasons. Never reconstruct receipts from prose or count-only summaries. Feature receipts start as `heuristic`; verify their cited files with exact reads before changing `evidence_kind` to `verified`. Uncovered subsystems, unreachable implementations, documented-but-unimplemented behavior, tests without runtime wiring, or runtime paths without failure-state coverage block a complete audit. Only after the coverage result is complete should you synthesize and proceed to implementation or the answer. +3. **Shard by feature slices and structure.** Make vertical feature slices (entrypoint or UI/command → orchestrator/runtime → service/storage/provider → tests/docs/failure states) the primary reasoning shards. Add structural package shards and cross-cutting domain shards for security, compatibility, performance, accessibility, migration, and reliability. Attach the inventory's language/framework capability packet instead of selecting a language-specific agent. These reasoning/audit shards are `general-agent` shards invoked with the `write_audit_findings` tool (passing the `sessionSlug`, `shardId`, and `snapshotId`) — that tool is what emits each shard's `structuralReceipt`, and these are the receipts that feed `evaluate_audit_coverage`. The `file-picker` discovery shards from steps 1–2 are inputs to these audit shards: they hand over prose and paths, they do not produce receipts. Each shard must return the subsystem IDs and feature IDs it actually covered. +4. **Machine-check completeness before synthesis.** Run `inspect_feature_completeness` for every claimed or discovered user-visible feature, then `evaluate_audit_coverage` with the exact inventory snapshot, each audit shard's returned `structuralReceipt` (these come only from the `general-agent` + `write_audit_findings` audit shards of step 3, never from the discovery-only `file-picker` shards), each feature inspection's returned `coverageReceipt`, and explicit out-of-scope reasons. Never reconstruct receipts from prose or count-only summaries. Feature receipts start as `heuristic`; verify their cited files with exact reads before changing `evidence_kind` to `verified`. Uncovered subsystems, unreachable implementations, documented-but-unimplemented behavior, tests without runtime wiring, or runtime paths without failure-state coverage block a complete audit. Only after the coverage result is complete should you synthesize and proceed to implementation or the answer. Never make the user ask explicitly for "use multiple agents" — the scope assessment and breadth measurement above are your job, and the default for audit-style requests is parallel sharding, not a single codesearch. diff --git a/agents/guides/editor-writers-and-repair.md b/agents/guides/editor-writers-and-repair.md index 6790373d9b..3e13d1f461 100644 --- a/agents/guides/editor-writers-and-repair.md +++ b/agents/guides/editor-writers-and-repair.md @@ -121,7 +121,7 @@ Repair budgets may be unlimited by default or capped via createBase2 / env (`OPE | Combination | Allowed? | Notes | | ---------------------------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| Context agents (file-picker, code-searcher, researchers) in parallel | Yes | Bounded waves ≤8 per `spawn_agents` call; join before dependent edits. | +| Context agents (file-picker, researchers) in parallel | Yes | Bounded waves ≤8 per `spawn_agents` call; join before dependent edits. | | Multiple bashers for independent validation commands | Yes | Join all results before finalizing. Sequential if command B depends on A. | | Static `code-reviewer` / specialists **with** validation still running | Only if review is explicitly validation-independent | Parallel approval is **not** final until validation completes. Prefer validation first for fragile harness/editor work, then review with the summary. | | Routed specialists in one `spawn_agents` batch | Yes (runtime-owned) | Gate batches selected specialists; attestation/retry is gate-owned. | diff --git a/agents/patterns/audit-codebase.md b/agents/patterns/audit-codebase.md index 0a09bd384b..d2f328935d 100644 --- a/agents/patterns/audit-codebase.md +++ b/agents/patterns/audit-codebase.md @@ -119,8 +119,9 @@ editor/reviewer rather than creating a language-specific agent. (detected by `classifyBreadth` in `evals/buffbench/plan-sharding-signals.ts`), you MUST spawn at least `max(domainCount, 5)` shard **pairs** — never fewer than 5, even if fewer domains were enumerated. A "pair" = one `file-picker` -subagent (discovers files) + one `code-searcher` subagent (finds patterns); a -shard with only one type does not count toward the minimum. This rule is +subagent (discovers files) + one `general-agent` audit shard (analyzes them and +persists findings); a shard with only one type does not count toward the +minimum. This rule is machine-checked by the pure function `evaluateMinimumShardRule`, which is wired into `evaluateShardingVerdict` as an additional gate: a `broad-audit` trace that shards but falls short of the minimum-pair bar fails with a clear reason. @@ -153,12 +154,12 @@ parent receives only compact receipts, so raw findings never occupy its context. **Pair composition (M10.2):** Each shard pair MUST include both a -`file-picker` (discovers the shard's files) and a `code-searcher` (finds -patterns across them). A shard that runs only a `file-picker` or only a -`code-searcher` does **not** count toward the minimum-shard floor — the -minimum is measured in complete pairs (`min(file-picker, code-searcher)`), not -raw subagent count. So a trace with 10 `file-picker`s and 0 `code-searcher`s -has 0 pairs and fails the rule. +`file-picker` (discovers the shard's files) and a `general-agent` audit shard +(analyzes them and persists findings). A shard that runs only a `file-picker` +or only a `general-agent` does **not** count toward the minimum-shard floor — +the minimum is measured in complete pairs (`min(file-picker, general-agent)`), +not raw subagent count. So a trace with 10 `file-picker`s and 0 +`general-agent`s has 0 pairs and fails the rule. ### Step 3.5 — Machine-check the coverage matrix diff --git a/agents/tool-reachability.test.ts b/agents/tool-reachability.test.ts index e7286dbfd0..4d304c7cd2 100644 --- a/agents/tool-reachability.test.ts +++ b/agents/tool-reachability.test.ts @@ -8,7 +8,6 @@ import browserUse from './browser-use/browser-use' import dependencyManager from './dependency-manager/dependency-manager' import docWriter from './doc-writer/doc-writer' import { createCodeEditor } from './editor/editor' -import codeSearcher from './file-explorer/code-searcher' import directoryLister from './file-explorer/directory-lister' import filePicker from './file-explorer/file-picker' import globMatcher from './file-explorer/glob-matcher' @@ -243,7 +242,6 @@ describe('agent prompt/tool availability alignment', () => { basher, librarian, globMatcher, - codeSearcher, directoryLister, filePicker, browserUse, diff --git a/agents/types/tools.ts b/agents/types/tools.ts index 70d9a894f0..172984f5bd 100644 --- a/agents/types/tools.ts +++ b/agents/types/tools.ts @@ -961,17 +961,6 @@ export interface SpawnAgentsParams { failure_pattern?: string /** Maximum extracted failure lines to return with save_full_log (basher) */ max_failure_lines?: number - /** Array of code search queries (code-searcher) */ - searchQueries?: { - /** The pattern to search for */ - pattern: string - /** 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. */ - flags?: string | string[] - /** Optional working directory relative to project root */ - cwd?: string - /** Max results per file. Default 15 */ - maxResults?: number - }[] /** Relevant file paths to read (general-agent) */ filePaths?: string[] /** Relevant directory paths to inventory (general-agent) */ diff --git a/cli/knowledge.md b/cli/knowledge.md index f5371fc2ae..b12174ab66 100644 --- a/cli/knowledge.md +++ b/cli/knowledge.md @@ -902,6 +902,8 @@ Streaming markdown renders as plain text until the message or agent finishes. Th - `cli/src/utils/create-run-config.ts` and regenerated agent type sources track content-search/glob `cwd` ergonomics (file-as-cwd coercion, paths param, flag allowlist). After public tool schema changes, regenerate `cli/src/data/initial-agent-type-sources.generated.ts` via the root tool-definition generator. - `cli/src/components/blocks/blocks-renderer.tsx` wraps every block and block group in its own nested `ErrorBoundary` (`isolateBlock`), with the React key on the boundary. Keep new block handlers routed through it: `@opentui/react`'s root boundary is app-wide, so an unguarded render throw in one persisted block blanks the whole session on every reload. +- _Knowledge refresh 2026-09-03: agent-branch status display (`cli/src/components/blocks/agent-branch-wrapper.tsx`), status-label/chip utilities (`cli/src/utils/status-label.ts`, `cli/src/utils/status-bar-chips.ts`), and code-search summary rendering (`cli/src/utils/code-search-summary.ts`) changed alongside the code-searcher removal; regenerated agent type sources kept in sync._ + - `cli/src/components/renderers/compaction-box.tsx` derives the pending/interrupted/unsettled triple in one `derivePresentation` helper that both `deriveTone` and the render path consume, so the chosen tone and the rendered lines cannot drift. A `status: 'pending'` block is only presented as live when `isLiveCompaction` confirms it belongs to THIS process (matching `liveSessionId`); a replayed pending block from a persisted transcript renders as "Interrupted before this pass reported a result." rather than a permanently spinning "Compacting context…" card. `cli/src/utils/sdk-event-handlers.ts` consumes the additive `context_compaction_status` event and pairs `started`/`settled` strictly by the event's required `runId` — never by `agentId`, which subagent forwarding rewrites — so a nested agent loop's settle cannot clear the root turn's live card; `handleFinish` rewrites any stray pending block as interrupted so an aborted turn leaves an honest terminal record. - _Knowledge refresh 2026-08-23: add `/memory` (alias `/mem`) slash command; staleness guard touch._ diff --git a/cli/src/components/blocks/agent-branch-wrapper.tsx b/cli/src/components/blocks/agent-branch-wrapper.tsx index 35801cf040..ae80d273c6 100644 --- a/cli/src/components/blocks/agent-branch-wrapper.tsx +++ b/cli/src/components/blocks/agent-branch-wrapper.tsx @@ -26,7 +26,6 @@ import { processBlocks, type BlockProcessorHandlers, } from '../../utils/block-processor' -import { getCodeSearcherCollapsedPreview } from '../../utils/code-search-summary' import { shouldRenderAsSimpleText } from '../../utils/constants' import { isImplementorAgent, @@ -71,11 +70,6 @@ function getCollapsedPreview( } } - const codeSearcherPreview = getCodeSearcherCollapsedPreview(agentBlock) - if (codeSearcherPreview) { - return codeSearcherPreview - } - // Default preview: use the displayed prompt or first line of text content. const displayPrompt = getAgentDisplayPrompt(agentBlock) if (displayPrompt) { diff --git a/cli/src/data/initial-agent-type-sources.generated.ts b/cli/src/data/initial-agent-type-sources.generated.ts index 3b339141b7..40c450e8f5 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/. 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 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 /** 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/cli/src/utils/__tests__/agent-display.test.ts b/cli/src/utils/__tests__/agent-display.test.ts index 2f05ec91f1..ed365d8f66 100644 --- a/cli/src/utils/__tests__/agent-display.test.ts +++ b/cli/src/utils/__tests__/agent-display.test.ts @@ -58,8 +58,8 @@ describe('getAgentDisplayPrompt', () => { test('ignores non-basher what_to_summarize params', () => { const block = createAgentBlock({ - agentName: 'code-searcher', - agentType: 'code-searcher', + agentName: 'file-picker', + agentType: 'file-picker', params: { what_to_summarize: 'This is not a basher prompt', }, @@ -121,7 +121,7 @@ describe('getBasherFinishedOutputPreview', () => { test('ignores non-basher output', () => { const block = createAgentBlock({ - agentType: 'code-searcher', + agentType: 'file-picker', status: 'complete', blocks: [{ type: 'text', content: 'Search results' }], }) diff --git a/cli/src/utils/__tests__/block-processor.test.ts b/cli/src/utils/__tests__/block-processor.test.ts index 713d41d4fa..724d49d50e 100644 --- a/cli/src/utils/__tests__/block-processor.test.ts +++ b/cli/src/utils/__tests__/block-processor.test.ts @@ -453,7 +453,7 @@ describe('processBlocks', () => { const blocks: ContentBlock[] = [ createNonImplementorAgent('fp-1', 'file-picker'), createNonImplementorAgent('b-1', 'basher'), - createNonImplementorAgent('cs-1', 'code-searcher'), + createNonImplementorAgent('dl-1', 'directory-lister'), ] const result = processBlocks(blocks, handlers) @@ -465,7 +465,7 @@ describe('processBlocks', () => { expect(agentBlocks).toHaveLength(3) expect(agentBlocks[0].agentType).toBe('file-picker') expect(agentBlocks[1].agentType).toBe('basher') - expect(agentBlocks[2].agentType).toBe('code-searcher') + expect(agentBlocks[2].agentType).toBe('directory-lister') }) test('groups consecutive non-implementor agents including mixed sizes', () => { @@ -473,7 +473,7 @@ describe('processBlocks', () => { const blocks: ContentBlock[] = [ createNonImplementorAgent('fp-1', 'file-picker'), createNonImplementorAgent('cr-1', 'code-reviewer'), - createNonImplementorAgent('cs-1', 'code-searcher'), + createNonImplementorAgent('dl-1', 'directory-lister'), ] const result = processBlocks(blocks, handlers) @@ -486,7 +486,7 @@ describe('processBlocks', () => { expect(agentBlocks).toHaveLength(3) expect(agentBlocks[0].agentType).toBe('file-picker') expect(agentBlocks[1].agentType).toBe('code-reviewer') - expect(agentBlocks[2].agentType).toBe('code-searcher') + expect(agentBlocks[2].agentType).toBe('directory-lister') }) test('separates non-implementor groups from other block types', () => { @@ -624,7 +624,7 @@ describe('processBlocks', () => { createReasoningBlock('considering options'), createTextBlock('I will search for files first'), createNonImplementorAgent('fp-1', 'file-picker'), - createNonImplementorAgent('cs-1', 'code-searcher'), + createNonImplementorAgent('dl-1', 'directory-lister'), createTextBlock('Now I will make changes'), createImplementorAgent('impl-1', 'editor-implementor'), createImplementorAgent('impl-2', 'editor-implementor'), @@ -733,7 +733,7 @@ describe('processBlocks', () => { createNonImplementorAgent('fp-1', 'file-picker'), // index 1 createNonImplementorAgent('b-1', 'basher'), // index 2 createNonImplementorAgent('cr-1', 'code-reviewer'), // index 3 - createNonImplementorAgent('cs-1', 'code-searcher'), // index 4 + createNonImplementorAgent('dl-1', 'directory-lister'), // index 4 createTextBlock('text at 5'), ] @@ -774,7 +774,7 @@ describe('splitAgentsBySize', () => { const agents = [ createNonImplementorAgent('fp-1', 'file-picker'), createNonImplementorAgent('b-1', 'basher'), - createNonImplementorAgent('cs-1', 'code-searcher'), + createNonImplementorAgent('dl-1', 'directory-lister'), ] const result = splitAgentsBySize(agents) expect(result).toEqual([agents]) diff --git a/cli/src/utils/__tests__/code-search-summary.test.ts b/cli/src/utils/__tests__/code-search-summary.test.ts index 7746d3b2e1..5d373c5a39 100644 --- a/cli/src/utils/__tests__/code-search-summary.test.ts +++ b/cli/src/utils/__tests__/code-search-summary.test.ts @@ -1,42 +1,6 @@ import { describe, expect, test } from 'bun:test' -import { - countCodeSearchResults, - getCodeSearcherCollapsedPreview, -} from '../code-search-summary' - -import type { AgentContentBlock, ToolContentBlock } from '../../types/chat' - -const createCodeSearchToolBlock = ( - output: string, - id = 'tool-1', -): ToolContentBlock => ({ - type: 'tool', - toolCallId: id, - toolName: 'code_search', - input: { pattern: 'MODEL_ID' }, - output, -}) - -const createCodeSearcherBlock = ( - options: Partial = {}, -): AgentContentBlock => ({ - type: 'agent', - agentId: 'agent-1', - agentName: 'code-searcher', - agentType: 'code-searcher', - content: '', - status: 'complete', - params: { - searchQueries: [ - { pattern: 'OPENBUFF_MODEL_SELECTOR_MODELS' }, - { pattern: 'OPENBUFF_MODEL_SELECTOR_MODEL_IDS' }, - { pattern: 'DEFAULT_OPENBUFF_MODEL_ID' }, - ], - }, - blocks: [], - ...options, -}) +import { countCodeSearchResults } from '../code-search-summary' describe('code search summary helpers', () => { test('counts formatted code search matches from stdout', () => { @@ -48,37 +12,4 @@ describe('code search summary helpers', () => { Line 196: getAgentBaseName(options.agentType ?? '') === 'code-searcher'`), ).toBe(2) }) - - test('summarizes collapsed code-searcher searches and results', () => { - const agentBlock = createCodeSearcherBlock({ - blocks: [ - createCodeSearchToolBlock('Found 7 matches', 'tool-1'), - createCodeSearchToolBlock('Found 2 matches', 'tool-2'), - createCodeSearchToolBlock('Found 7 matches', 'tool-3'), - ], - }) - - expect(getCodeSearcherCollapsedPreview(agentBlock)).toBe( - '3 searches · 16 results', - ) - }) - - test('shows search count before tool outputs arrive', () => { - expect(getCodeSearcherCollapsedPreview(createCodeSearcherBlock())).toBe( - '3 searches', - ) - }) - - test('handles singular labels', () => { - const agentBlock = createCodeSearcherBlock({ - params: { - searchQueries: [{ pattern: 'DEFAULT_OPENBUFF_MODEL_ID' }], - }, - blocks: [createCodeSearchToolBlock('Found 1 match')], - }) - - expect(getCodeSearcherCollapsedPreview(agentBlock)).toBe( - '1 search · 1 result', - ) - }) }) diff --git a/cli/src/utils/__tests__/message-block-helpers.test.ts b/cli/src/utils/__tests__/message-block-helpers.test.ts index a54e40a612..9a892b4368 100644 --- a/cli/src/utils/__tests__/message-block-helpers.test.ts +++ b/cli/src/utils/__tests__/message-block-helpers.test.ts @@ -47,7 +47,7 @@ describe('getAgentBaseName', () => { }) test('normalizes direct tool aliases to canonical agent names', () => { - expect(getAgentBaseName('code_searcher')).toBe('code-searcher') + expect(getAgentBaseName('file_picker')).toBe('file-picker') }) test('handles scoped name without version', () => { @@ -1324,7 +1324,7 @@ describe('moveSpawnAgentBlock', () => { type: 'agent', agentId: 'toolcall-1', agentName: 'Agent B', - agentType: 'code-searcher', + agentType: 'file-picker', content: '', status: 'running', blocks: [], diff --git a/cli/src/utils/__tests__/send-message-helpers.test.ts b/cli/src/utils/__tests__/send-message-helpers.test.ts index cb44868bff..e6b45aebd3 100644 --- a/cli/src/utils/__tests__/send-message-helpers.test.ts +++ b/cli/src/utils/__tests__/send-message-helpers.test.ts @@ -1380,7 +1380,7 @@ describe('getAgentBaseName', () => { }) test('normalizes direct tool aliases to canonical agent names', () => { - expect(getAgentBaseName('code_searcher')).toBe('code-searcher') + expect(getAgentBaseName('file_picker')).toBe('file-picker') }) }) @@ -1400,7 +1400,7 @@ describe('agentTypesMatch', () => { test('does not match different base names', () => { expect( - getAgentBaseName('file-picker') === getAgentBaseName('code-searcher'), + getAgentBaseName('file-picker') === getAgentBaseName('directory-lister'), ).toBe(false) }) }) @@ -1589,7 +1589,7 @@ describe('createSpawnAgentBlocks', () => { test('creates agent blocks from spawn_agents input', () => { const agents = [ { agent_type: 'file-picker', prompt: 'Find files' }, - { agent_type: 'code-searcher', prompt: 'Search code' }, + { agent_type: 'directory-lister', prompt: 'List directories' }, ] const result = createSpawnAgentBlocks('tool-1', agents) diff --git a/cli/src/utils/code-search-summary.ts b/cli/src/utils/code-search-summary.ts index 307b1bd5df..5753811a8b 100644 --- a/cli/src/utils/code-search-summary.ts +++ b/cli/src/utils/code-search-summary.ts @@ -1,11 +1,3 @@ -import { getAgentBaseName } from './message-block-helpers' - -import type { - AgentContentBlock, - ContentBlock, - ToolContentBlock, -} from '../types/chat' - export function countCodeSearchResults(output?: string): number { if (!output) { return 0 @@ -28,43 +20,3 @@ export function countCodeSearchResults(output?: string): number { return /^(?:Line\s+)?\d+:/.test(trimmed) ? total + 1 : total }, 0) } - -const pluralize = (count: number, singular: string, plural = `${singular}s`) => - `${count} ${count === 1 ? singular : plural}` - -const isCodeSearchToolBlock = ( - block: ContentBlock, -): block is ToolContentBlock => - block.type === 'tool' && block.toolName === 'code_search' - -export function getCodeSearcherCollapsedPreview( - agentBlock: AgentContentBlock, -): string | undefined { - if (getAgentBaseName(agentBlock.agentType) !== 'code-searcher') { - return undefined - } - - const toolBlocks = (agentBlock.blocks ?? []).filter(isCodeSearchToolBlock) - const searchQueries = Array.isArray(agentBlock.params?.searchQueries) - ? agentBlock.params.searchQueries - : [] - const searchCount = searchQueries.length || toolBlocks.length - - if (searchCount === 0) { - return undefined - } - - const completedToolBlocks = toolBlocks.filter((block) => block.output) - const searchLabel = pluralize(searchCount, 'search', 'searches') - - if (completedToolBlocks.length === 0) { - return searchLabel - } - - const totalResults = completedToolBlocks.reduce( - (total, block) => total + countCodeSearchResults(block.output), - 0, - ) - - return `${searchLabel} · ${pluralize(totalResults, 'result')}` -} diff --git a/cli/src/utils/constants.ts b/cli/src/utils/constants.ts index 9478a04973..ee6832e329 100644 --- a/cli/src/utils/constants.ts +++ b/cli/src/utils/constants.ts @@ -28,7 +28,6 @@ export const COLLAPSED_BY_DEFAULT_AGENT_IDS = [ 'file-picker', 'code-reviewer-selector', 'basher', - 'code-searcher', 'directory-lister', 'glob-matcher', 'researcher-web', diff --git a/cli/src/utils/status-label.ts b/cli/src/utils/status-label.ts index 2d8964d8a5..65365ba2ca 100644 --- a/cli/src/utils/status-label.ts +++ b/cli/src/utils/status-label.ts @@ -5,7 +5,6 @@ const AGENT_TYPE_LABELS: Array<{ pattern: string; label: string }> = [ { pattern: 'file-picker', label: 'gathering context...' }, - { pattern: 'code-searcher', label: 'searching codebase...' }, { pattern: 'researcher-web', label: 'searching the web...' }, { pattern: 'researcher-docs', label: 'reading documentation...' }, { pattern: 'editor', label: 'editing...' }, diff --git a/common/knowledge.md b/common/knowledge.md index a0d76f722e..6cb61b563d 100644 --- a/common/knowledge.md +++ b/common/knowledge.md @@ -65,6 +65,8 @@ This package contains code shared across the Openbuff monorepo, especially the l - _Knowledge refresh 2026-08-31 (external read roots): `common/src/util/project-path-containment.ts` gained the read-only `external-read` scope, its default-closed configure-once registry (`configureExternalReadRoots` / `ensureExternalReadRootsConfigured` / `getExternalReadRoots` / `resetExternalReadRootsForTesting`), the `isExternalReadPath` predicate, and the `resolveProjectPathForRead` / `resolveProjectPathForFileSystemRead` entry points; `common/src/util/sensitive-paths.ts` gained the openbuff credential basenames plus the path-aware credential carriers. Consumers: the four SDK read handlers (`read-files`, `read-logs`, `read-image`, `list-directory`) via `sdk/src/tools/path-utils.ts` read-only resolvers, the `readableRoots` config field and its provenance-based trust gate in `sdk/src/provider-config.ts` + `sdk/src/run.ts` (`selectTrustedReadableRoots`, gated by `OPENBUFF_TRUST_PROJECT_READABLE_ROOTS` because project config wins the config merge), and the tool-scoped backstop exemption in `packages/agent-runtime/src/tools/tool-executor.ts` (`EXTERNAL_READ_EXEMPT_TOOLS`)._ +- _Knowledge refresh 2026-09-03: background-agent job lifecycle and audit-receipt helpers in `common/src/util/job-registry.ts` / `common/src/util/audit-receipt.ts` hardened alongside the agent-runtime fixes; content-search params aligned after the code-searcher removal._ + - _Knowledge refresh 2026-09-01 (request-time context trim): `common/src/types/print-mode.ts` gained the additive `context_request_trim` variant reporting the SDK's request-time emergency trim — the last-line-of-defense drop applied at dispatch when a request's messages still exceed the provider-safe budget after every runtime brake ran. It carries required `messageBudgetTokens`/`beforeTokens`/`afterTokens`/`beforeMessages`/`afterMessages` plus optional `runId`, `ancestorRunIds`, `agentId`, `resolvedContextWindowTokens`, and `model`. It is a DIFFERENT brake from `context_compaction` and its `context_compaction_status` pair, so the two must never be merged or counted as one pass. The existing `context_window` variant gained optional `compactionTriggerTokens`/`compactionTargetTokens` reporting the runtime's model-aware semantic-compaction budget; both are derived from the RAW resolved model window and are deliberately NOT clamped by `maxContextLength`, so `compactionTriggerTokens > max` is a legitimate payload and a consumer rendering trigger against `max` must clamp or suppress it itself. `common/src/types/contracts/llm.ts` re-exports the `RequestContextTrimInfo` payload type (declared in `print-mode`) for the optional `onRequestContextTrimmed` callback on the published `promptAiSdk`/`promptAiSdkStream`/`promptAiSdkStructured` signatures: purely observational, fires only when the trim actually dropped messages, can never affect the trim result, and a throwing consumer is caught and logged rather than aborting dispatch. All three additions are additive/optional, so callers that omit them and consumers that ignore unknown `event.type` values keep their previous behavior._ ## Scope Notes diff --git a/common/src/constants/agents.ts b/common/src/constants/agents.ts index 03f8e6617b..3f9393399c 100644 --- a/common/src/constants/agents.ts +++ b/common/src/constants/agents.ts @@ -38,10 +38,6 @@ export const AGENT_PERSONAS = { displayName: 'Nit Pick Nick', purpose: 'Reviews file changes and responds with critical feedback.', } as const, - 'code-searcher': { - displayName: 'Code Searcher', - purpose: 'Expert at searching the codebase for relevant code.', - } as const, 'file-lister': { displayName: 'Liszt the File Lister', purpose: 'Lists files relevant to a task.', diff --git a/common/src/constants/prompt-sections.ts b/common/src/constants/prompt-sections.ts index 8803e156f8..eb6c6a03a8 100644 --- a/common/src/constants/prompt-sections.ts +++ b/common/src/constants/prompt-sections.ts @@ -97,13 +97,13 @@ export function buildBroadAuditSection( For broad, open-ended, or audit-style requests (for example: "check this codebase for any feature improvements", "audit the codebase for security/correctness/perf issues", "assess this codebase for how production ready it is on a feature, security and code level", "find all the places X is handled", "what can be improved in the agents/sdk/cli", or anything where the relevant surface is not already obvious), do NOT default to a single surface-level codesearch or one or two file reads. Instead, run a deliberate scope-then-shard flow: 1. **Assess scope and measure breadth.** The runtime starts cross-subsystem requests with \`inspect_codebase_structure\`; treat its snapshot-bound subsystem, entrypoint, route, command, public-API, test, generated-source, and language/framework capability inventory as authoritative for shard allocation. Supplement it with query_index only for semantic discovery. Count the distinct subsystems / packages / concerns the request spans. Pick the shard count from this adaptive rubric (breadth = number of distinct subsystems the request touches): - - **breadth 1–2 (focused):** one shard pair per subsystem (one file-picker + one code-searcher), plus a docs researcher if a major external library is involved. - - **breadth 3–5 (multi-subsystem audit):** at least one complete file-picker/code-searcher pair per subsystem. Dispatch the pairs in bounded waves when they exceed the per-call limit. - - **breadth 6+ (whole-codebase audit):** at least one complete file-picker/code-searcher pair per subsystem, plus one researcher-docs per major external library involved, dispatched in bounded waves. - The \`file-picker\` and \`code-searcher\` shards named above are DISCOVERY-ONLY: they return prose and file paths, not receipts, and cannot emit a \`structuralReceipt\`. Their output feeds the reasoning/audit shards (step 3), it is not passed to \`evaluate_audit_coverage\` directly. The wider the surface, the more shards. Each call must respect the advertised batch limit, but there is no fixed total-agent limit: join a wave, evaluate coverage, and launch another until the inventory is covered. Never default to a single codesearch for an audit-style request. + - **breadth 1–2 (focused):** one shard pair per subsystem (one file-picker for discovery + one general-agent audit shard for analysis), plus a docs researcher if a major external library is involved. + - **breadth 3–5 (multi-subsystem audit):** at least one complete file-picker + general-agent audit-shard pair per subsystem. Dispatch the pairs in bounded waves when they exceed the per-call limit. + - **breadth 6+ (whole-codebase audit):** at least one complete file-picker + general-agent audit-shard pair per subsystem, plus one researcher-docs per major external library involved, dispatched in bounded waves. + The \`file-picker\` shards named above are DISCOVERY-ONLY: they return prose and file paths, not receipts, and cannot emit a \`structuralReceipt\`. Their output feeds the paired \`general-agent\` audit shard (step 3), which is the shard that emits \`structuralReceipt\` via \`write_audit_findings\`; discovery output is not passed to \`evaluate_audit_coverage\` directly. The wider the surface, the more shards. Each call must respect the advertised batch limit, but there is no fixed total-agent limit: join a wave, evaluate coverage, and launch another until the inventory is covered. Never default to a single codesearch for an audit-style request. 2. **Check frontend presence and coverage.** If top-level dirs, routes, pages, app/, src/, components/, or framework config indicate a frontend exists, the audit must cover UI page wiring, routes, navigation, API integration, auth/error/loading states, accessibility, and responsiveness. If no frontend is present, explicitly mark frontend/UI coverage out-of-scope rather than silently omitting it. -3. **Shard by feature slices and structure.** Make vertical feature slices (entrypoint or UI/command → orchestrator/runtime → service/storage/provider → tests/docs/failure states) the primary reasoning shards. Add structural package shards and cross-cutting domain shards for security, compatibility, performance, accessibility, migration, and reliability. Attach the inventory's language/framework capability packet instead of selecting a language-specific agent. These reasoning/audit shards are \`general-agent\` shards invoked with the \`write_audit_findings\` tool (passing the \`sessionSlug\`, \`shardId\`, and \`snapshotId\`) — that tool is what emits each shard's \`structuralReceipt\`, and these are the receipts that feed \`evaluate_audit_coverage\`. The \`file-picker\`/\`code-searcher\` discovery shards from steps 1–2 are inputs to these audit shards: they hand over prose and paths, they do not produce receipts. Each shard must return the subsystem IDs and feature IDs it actually covered. -4. **Machine-check completeness before synthesis.** Run \`inspect_feature_completeness\` for every claimed or discovered user-visible feature, then \`evaluate_audit_coverage\` with the exact inventory snapshot, each audit shard's returned \`structuralReceipt\` (these come only from the \`general-agent\` + \`write_audit_findings\` audit shards of step 3, never from the discovery-only \`file-picker\`/\`code-searcher\` shards), each feature inspection's returned \`coverageReceipt\`, and explicit out-of-scope reasons. Never reconstruct receipts from prose or count-only summaries. Feature receipts start as \`heuristic\`; verify their cited files with exact reads before changing \`evidence_kind\` to \`verified\`. Uncovered subsystems, unreachable implementations, documented-but-unimplemented behavior, tests without runtime wiring, or runtime paths without failure-state coverage block a complete audit. Only after the coverage result is complete should you synthesize and ${finalizeClause}. +3. **Shard by feature slices and structure.** Make vertical feature slices (entrypoint or UI/command → orchestrator/runtime → service/storage/provider → tests/docs/failure states) the primary reasoning shards. Add structural package shards and cross-cutting domain shards for security, compatibility, performance, accessibility, migration, and reliability. Attach the inventory's language/framework capability packet instead of selecting a language-specific agent. These reasoning/audit shards are \`general-agent\` shards invoked with the \`write_audit_findings\` tool (passing the \`sessionSlug\`, \`shardId\`, and \`snapshotId\`) — that tool is what emits each shard's \`structuralReceipt\`, and these are the receipts that feed \`evaluate_audit_coverage\`. The \`file-picker\` discovery shards from steps 1–2 are inputs to these audit shards: they hand over prose and paths, they do not produce receipts. Each shard must return the subsystem IDs and feature IDs it actually covered. +4. **Machine-check completeness before synthesis.** Run \`inspect_feature_completeness\` for every claimed or discovered user-visible feature, then \`evaluate_audit_coverage\` with the exact inventory snapshot, each audit shard's returned \`structuralReceipt\` (these come only from the \`general-agent\` + \`write_audit_findings\` audit shards of step 3, never from the discovery-only \`file-picker\` shards), each feature inspection's returned \`coverageReceipt\`, and explicit out-of-scope reasons. Never reconstruct receipts from prose or count-only summaries. Feature receipts start as \`heuristic\`; verify their cited files with exact reads before changing \`evidence_kind\` to \`verified\`. Uncovered subsystems, unreachable implementations, documented-but-unimplemented behavior, tests without runtime wiring, or runtime paths without failure-state coverage block a complete audit. Only after the coverage result is complete should you synthesize and ${finalizeClause}. Never make the user ask explicitly for "use multiple agents" — the scope assessment and breadth measurement above are your job, and the default for audit-style requests is parallel sharding, not a single codesearch.` } diff --git a/common/src/templates/initial-agents-dir/types/tools.ts b/common/src/templates/initial-agents-dir/types/tools.ts index 70d9a894f0..172984f5bd 100644 --- a/common/src/templates/initial-agents-dir/types/tools.ts +++ b/common/src/templates/initial-agents-dir/types/tools.ts @@ -961,17 +961,6 @@ export interface SpawnAgentsParams { failure_pattern?: string /** Maximum extracted failure lines to return with save_full_log (basher) */ max_failure_lines?: number - /** Array of code search queries (code-searcher) */ - searchQueries?: { - /** The pattern to search for */ - pattern: string - /** 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. */ - flags?: string | string[] - /** Optional working directory relative to project root */ - cwd?: string - /** Max results per file. Default 15 */ - maxResults?: number - }[] /** Relevant file paths to read (general-agent) */ filePaths?: string[] /** Relevant directory paths to inventory (general-agent) */ diff --git a/common/src/tools/__tests__/spawn-agents-schema.test.ts b/common/src/tools/__tests__/spawn-agents-schema.test.ts index 90dd076a6e..a57d5a1328 100644 --- a/common/src/tools/__tests__/spawn-agents-schema.test.ts +++ b/common/src/tools/__tests__/spawn-agents-schema.test.ts @@ -58,10 +58,8 @@ describe('spawn_agents handoff schema', () => { it('repairs double-stringified lists and stringified agent entries', () => { const entry = { - agent_type: 'code-searcher', - params: { - searchQueries: [{ pattern: 'authenticate', flags: ['-g', '*.ts'] }], - }, + agent_type: 'file-picker', + params: { directories: ['src', 'cli'] }, } for (const agents of [ JSON.stringify(JSON.stringify([entry])), @@ -77,11 +75,9 @@ describe('spawn_agents handoff schema', () => { const result = spawnAgentsParams.inputSchema.safeParse({ agents: [ { - agent_type: 'code-searcher', + agent_type: 'security-reviewer', params: { - searchQueries: JSON.stringify([ - { pattern: 'Helmet', flags: "-g '*.tsx'" }, - ]), + changed_files: JSON.stringify(['src/a.ts', 'src/b.ts']), }, }, ], @@ -89,8 +85,9 @@ describe('spawn_agents handoff schema', () => { expect(result.success).toBe(true) if (result.success) { - expect(result.data.agents[0]?.params?.searchQueries).toEqual([ - { pattern: 'Helmet', flags: "-g '*.tsx'" }, + expect(result.data.agents[0]?.params?.changed_files).toEqual([ + 'src/a.ts', + 'src/b.ts', ]) } }) @@ -154,11 +151,11 @@ describe('spawn_agents common params fields', () => { describe('live-catalog spawn enum', () => { const catalogSchema = buildSpawnAgentsProviderInputSchema([ 'file-picker', - 'code-searcher', + 'general-agent', ]) it('accepts visible hyphenated types and the underscore alias', () => { - for (const agent_type of ['file-picker', 'code-searcher', 'file_picker']) { + for (const agent_type of ['file-picker', 'general-agent', 'file_picker']) { expect( catalogSchema.safeParse({ agents: [{ agent_type }] }).success, ).toBe(true) diff --git a/common/src/tools/params/__tests__/coerce-to-array.test.ts b/common/src/tools/params/__tests__/coerce-to-array.test.ts index 399b1026bf..29852b5810 100644 --- a/common/src/tools/params/__tests__/coerce-to-array.test.ts +++ b/common/src/tools/params/__tests__/coerce-to-array.test.ts @@ -289,140 +289,7 @@ describe('normalizeSpawnAgentList', () => { expect(normalizeSpawnAgentList([entry])).toEqual([entry]) }) - it('moves a top-level code-searcher pattern into params.searchQueries', () => { - expect( - normalizeSpawnAgentList([ - { - agent_type: 'code-searcher', - pattern: 'foo', - flags: '-g *.ts', - params: {}, - }, - ]), - ).toEqual([ - { - agent_type: 'code-searcher', - pattern: 'foo', - flags: '-g *.ts', - params: { - searchQueries: [{ pattern: 'foo', flags: '-g *.ts' }], - }, - }, - ]) - }) - - it('builds code-searcher searchQueries from params.pattern', () => { - expect( - normalizeSpawnAgentList([ - { - agent_type: 'code-searcher', - params: { pattern: 'bar', cwd: 'src', maxResults: 12 }, - }, - ]), - ).toEqual([ - { - agent_type: 'code-searcher', - params: { - pattern: 'bar', - cwd: 'src', - maxResults: 12, - searchQueries: [{ pattern: 'bar', cwd: 'src', maxResults: 12 }], - }, - }, - ]) - }) - - it('builds code-searcher searchQueries from a patterns array', () => { - expect( - normalizeSpawnAgentList([ - { - agent_type: 'code-searcher', - patterns: ['one', 'two'], - params: {}, - }, - ]), - ).toEqual([ - { - agent_type: 'code-searcher', - patterns: ['one', 'two'], - params: { - searchQueries: [{ pattern: 'one' }, { pattern: 'two' }], - }, - }, - ]) - }) - - it('moves a top-level code-searcher searchQueries array into params', () => { - expect( - normalizeSpawnAgentList([ - { - agent_type: 'code-searcher', - searchQueries: [{ pattern: 'top' }], - params: {}, - }, - ]), - ).toEqual([ - { - agent_type: 'code-searcher', - searchQueries: [{ pattern: 'top' }], - params: { searchQueries: [{ pattern: 'top' }] }, - }, - ]) - }) - - it('wraps a single code-searcher searchQueries object into an array', () => { - expect( - normalizeSpawnAgentList([ - { - agent_type: 'code-searcher', - params: { searchQueries: { pattern: 'solo' } }, - }, - ]), - ).toEqual([ - { - agent_type: 'code-searcher', - params: { searchQueries: [{ pattern: 'solo' }] }, - }, - ]) - }) - - it('prefers nested code-searcher searchQueries over a top-level pattern', () => { - expect( - normalizeSpawnAgentList([ - { - agent_type: 'code-searcher', - pattern: 'top-level', - params: { searchQueries: [{ pattern: 'nested' }] }, - }, - ]), - ).toEqual([ - { - agent_type: 'code-searcher', - pattern: 'top-level', - params: { searchQueries: [{ pattern: 'nested' }] }, - }, - ]) - }) - - it('does not invent code-searcher patterns from prompt prose', () => { - expect( - normalizeSpawnAgentList([ - { - agent_type: 'code-searcher', - prompt: 'Search for normalizeSpawnAgentList', - params: {}, - }, - ]), - ).toEqual([ - { - agent_type: 'code-searcher', - prompt: 'Search for normalizeSpawnAgentList', - params: {}, - }, - ]) - }) - - it('does not move pattern fields for non-code-searcher agents', () => { + it('does not move pattern fields into params for any agent', () => { const entry = { agent_type: 'editor', pattern: 'should-not-move', @@ -498,16 +365,16 @@ describe('normalizeSpawnAgentList', () => { expect( normalizeSpawnAgentList([ { - agent_type: 'code-search', - prompt: 'Search the codebase', - params: { searchQueries: '["q1","q2"]' }, + agent_type: 'security-reviewer', + prompt: 'Review the changes', + params: { changed_files: '["src/a.ts","src/b.ts"]' }, }, ]), ).toEqual([ { - agent_type: 'code-search', - prompt: 'Search the codebase', - params: { searchQueries: ['q1', 'q2'] }, + agent_type: 'security-reviewer', + prompt: 'Review the changes', + params: { changed_files: ['src/a.ts', 'src/b.ts'] }, }, ]) }) diff --git a/common/src/tools/params/tool/spawn-agents.ts b/common/src/tools/params/tool/spawn-agents.ts index a015c34a72..9bee457686 100644 --- a/common/src/tools/params/tool/spawn-agents.ts +++ b/common/src/tools/params/tool/spawn-agents.ts @@ -119,30 +119,6 @@ const spawnAgentEntryFields = { .describe( 'Maximum extracted failure lines to return with save_full_log (basher)', ), - searchQueries: z - .array( - z.object({ - pattern: z.string().describe('The pattern to search for'), - flags: z - .union([z.string(), z.array(z.string())]) - .optional() - .describe( - '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.', - ), - cwd: z - .string() - .optional() - .describe( - 'Optional working directory relative to project root', - ), - maxResults: z - .number() - .optional() - .describe('Max results per file. Default 15'), - }), - ) - .optional() - .describe('Array of code search queries (code-searcher)'), filePaths: z .array(z.string()) .optional() @@ -319,9 +295,9 @@ const inputSchema = z const description = ` Spawn agents in parallel (up to batch max). Pass \`agents\` as a real array of objects — do not JSON.stringify entries. -- **\`agent_type\` must be a name from the live "You can spawn the following agents" catalog** (hyphenated ids; underscores accepted). It is an agent name (e.g. basher, code-searcher, general-agent), **not a tool name** (read_files, str_replace, …). Call tools directly; do not wrap them in spawn_agents. +- **\`agent_type\` must be a name from the live "You can spawn the following agents" catalog** (hyphenated ids; underscores accepted). It is an agent name (e.g. basher, file-picker, general-agent), **not a tool name** (read_files, str_replace, …). Call tools directly; do not wrap them in spawn_agents. - Prefer spawn_agents over single-agent tool aliases so multiple agents can run in parallel. Same nested \`prompt\` + \`params\` schema either way. -- Include required agent params (e.g. basher \`command\`, code-searcher \`searchQueries\`, git-committer \`owned_paths\`, librarian \`repoUrl\`, dependency-manager \`manager\`+\`operation\`, security-reviewer \`changed_files\`+\`snapshot_fingerprint\`, reviewer specialists \`snapshot_id\`, repair-editor versioned \`handoff\`). Agent-specific fields go in \`params\`, not only the prompt. +- Include required agent params (e.g. basher \`command\`, git-committer \`owned_paths\`, librarian \`repoUrl\`, dependency-manager \`manager\`+\`operation\`, security-reviewer \`changed_files\`+\`snapshot_fingerprint\`, reviewer specialists \`snapshot_id\`, repair-editor versioned \`handoff\`). Agent-specific fields go in \`params\`, not only the prompt. - \`background: true\` returns a jobId immediately; poll with check_background_agent. Example: @@ -336,10 +312,9 @@ ${$getNativeToolCallExampleString({ params: { command: 'npm test' }, }, { - agent_type: 'code-searcher', - params: { - searchQueries: [{ pattern: 'authenticate', flags: '-g *.ts' }], - }, + agent_type: 'file-picker', + prompt: 'Find the auth-related files', + params: { directories: ['src'] }, }, { agent_type: 'git-committer', diff --git a/common/src/tools/params/tool/write-audit-findings.ts b/common/src/tools/params/tool/write-audit-findings.ts index f15d1ffb92..07bca6db6b 100644 --- a/common/src/tools/params/tool/write-audit-findings.ts +++ b/common/src/tools/params/tool/write-audit-findings.ts @@ -277,6 +277,31 @@ const inputSchema = z } }) +/** + * Idempotency marker carried by an already-exists collision whose persisted + * artifact is BYTE-IDENTICAL to the findings this call renders. The artifact is + * created exclusively, so a collision proves an artifact for THIS shard is + * already at the derived path; content identity is what proves those persisted + * findings are this call's, which is the only case that may be reported as an + * idempotent success. The write itself did not happen, so such a call returns + * the rejection shape rather than a synthesized compact receipt; this marker is + * what the shared coverage gate accepts, so a retried shard is not left + * permanently uncoverable. + * + * A collision whose persisted contents DIFFER carries no marker at all: that + * call's findings are persisted nowhere, so it is rejected and must be written + * under a distinct shard id. `snapshot_id` is present exactly when the + * identical persisted artifact was rendered for that snapshot, so neither a + * legacy call without snapshotId nor a re-run under a different snapshot can + * satisfy a snapshot-bound gate with a stale artifact. + */ +export const auditFindingsAlreadyPersistedSchema = z.object({ + schema_version: z.literal(1), + shardId: z.string(), + artifactPath: z.string(), + snapshot_id: z.string().optional(), +}) + export const auditFindingsReceiptSchema = z.object({ artifactPath: z.string(), artifacts: z.array(z.string()).length(1), @@ -306,6 +331,15 @@ export const auditFindingsReceiptSchema = z.object({ export const auditFindingsErrorSchema = z.object({ errorMessage: z.string(), artifactPath: z.string(), + /** + * Present when the rejection was an already-exists collision, so a caller can + * tell a shard whose findings are already durably persisted from an ordinary + * failure. The write itself did not happen, so no compact success receipt is + * synthesized; `snapshot_id` is the field the shared coverage gate consumes + * and is set only when the persisted bytes are this call's findings AND attest + * to this call's snapshot. + */ + alreadyPersisted: auditFindingsAlreadyPersistedSchema.optional(), }) const toolName = 'write_audit_findings' @@ -313,7 +347,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. 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.`, + 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. Creation is exclusive: when an artifact already exists at the derived path and its contents are byte-identical to this call, no second write happens and the rejection carries a snapshot-bound alreadyPersisted marker the coverage gate accepts, so the retry stays composable; when the existing contents differ, the call is rejected, nothing from it is persisted, and those findings must be written under a distinct shard id. 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/common/src/tools/params/utils.ts b/common/src/tools/params/utils.ts index 140bb06bd3..7e23ee8490 100644 --- a/common/src/tools/params/utils.ts +++ b/common/src/tools/params/utils.ts @@ -403,7 +403,6 @@ const MAX_REJOINED_LENGTH = 65_536 * REPLACEMENT_PLACEHOLDER_KEYS) so it is not recreated for every entry. */ const ARRAY_PARAM_KEYS = [ - 'searchQueries', 'filePaths', 'directories', 'prompts', @@ -525,7 +524,7 @@ export function normalizeSpawnAgentList(value: unknown, depth = 0): unknown { // Provider tool-call serializers sometimes preserve an agent-specific // array as a JSON string inside an otherwise valid params object (for - // example, `searchQueries: "[...]"`). Decode only known array-shaped + // example, `changed_files: "[...]"`). Decode only known array-shaped // handoff fields; leave commands, prompts, and arbitrary custom values // untouched so intentional strings are never reinterpreted as data. for (const key of ARRAY_PARAM_KEYS) { @@ -551,117 +550,6 @@ export function normalizeSpawnAgentList(value: unknown, depth = 0): unknown { paramsRepaired = true } - // Code-searcher: recover params.searchQueries from explicit structured - // fields only (mirror basher command repair). Prefer nested params over - // top-level. Never invent patterns from prompt prose; leave ambiguous - // shapes untouched so Zod fails closed. - if (record.agent_type === 'code-searcher') { - const wrapSearchQueryObject = ( - value: unknown, - ): unknown[] | undefined => { - if ( - value === null || - typeof value !== 'object' || - Array.isArray(value) - ) { - return undefined - } - const query = value as Record - if ( - typeof query.pattern !== 'string' || - query.pattern.trim() === '' - ) { - return undefined - } - return [value] - } - - const buildQueryFromPattern = ( - pattern: string, - source: Record, - ): Record => { - const query: Record = { pattern } - if (typeof source.flags === 'string') query.flags = source.flags - if (typeof source.cwd === 'string') query.cwd = source.cwd - if ( - typeof source.maxResults === 'number' && - Number.isFinite(source.maxResults) - ) { - query.maxResults = source.maxResults - } - return query - } - - const nonEmptyStringPatterns = ( - value: unknown, - ): string[] | undefined => { - if (!Array.isArray(value) || value.length === 0) return undefined - if ( - !value.every( - (entry) => typeof entry === 'string' && entry.trim() !== '', - ) - ) { - return undefined - } - return value as string[] - } - - // Single object at params.searchQueries → one-element array. - if (paramsRecord.searchQueries !== undefined) { - const wrapped = wrapSearchQueryObject(paramsRecord.searchQueries) - if (wrapped) { - paramsRecord.searchQueries = wrapped - paramsRepaired = true - } - // Non-empty string that is not a JSON array was already left alone - // by ARRAY_PARAM_KEYS; do not reinterpret it here. - } else { - // Prefer nested structured fields over top-level aliases. - if ( - typeof paramsRecord.pattern === 'string' && - paramsRecord.pattern.trim() !== '' - ) { - paramsRecord.searchQueries = [ - buildQueryFromPattern(paramsRecord.pattern, paramsRecord), - ] - paramsRepaired = true - } else { - const nestedPatterns = nonEmptyStringPatterns(paramsRecord.patterns) - if (nestedPatterns) { - paramsRecord.searchQueries = nestedPatterns.map((pattern) => ({ - pattern, - })) - paramsRepaired = true - } else if (Array.isArray(record.searchQueries)) { - paramsRecord.searchQueries = record.searchQueries - paramsRepaired = true - } else { - const wrappedTop = wrapSearchQueryObject(record.searchQueries) - if (wrappedTop) { - paramsRecord.searchQueries = wrappedTop - paramsRepaired = true - } else if ( - typeof record.pattern === 'string' && - record.pattern.trim() !== '' - ) { - paramsRecord.searchQueries = [ - buildQueryFromPattern(record.pattern, record), - ] - paramsRepaired = true - } else { - const topPatterns = nonEmptyStringPatterns(record.patterns) - if (topPatterns) { - paramsRecord.searchQueries = topPatterns.map((pattern) => ({ - pattern, - })) - paramsRepaired = true - } - } - } - } - } - } - // Recover labelled v3 gate tokens from prose after compaction; never bare // hex. Snapshot-scoped specialists still verify the fingerprint against // the live review bundle, so recovery does not grant authority. diff --git a/common/src/util/__tests__/audit-receipt.test.ts b/common/src/util/__tests__/audit-receipt.test.ts index fdb501e6f7..656586c724 100644 --- a/common/src/util/__tests__/audit-receipt.test.ts +++ b/common/src/util/__tests__/audit-receipt.test.ts @@ -120,4 +120,98 @@ describe('containsStructuralAuditReceipt', () => { ), ).toBe(false) }) + + it('accepts an already-persisted collision marker bound to the expected snapshot', () => { + // write_audit_findings creates the artifact exclusively, so an + // already-exists rejection means THIS shard's findings are already durably + // at that path. The rejection carries no structuralReceipt, so the marker + // is what keeps the shard from burning its completion retries. + const artifactPath = '.agents/sessions/readiness/findings/services.md' + const collision = { + messageHistory: [ + { + role: 'tool', + content: [ + { + type: 'json', + value: { + artifactPath, + errorMessage: `Failed to create file: the file already exists. Shard id "services": this shard's findings are already persisted at ${artifactPath}.`, + alreadyPersisted: { + schema_version: 1, + shardId: 'services', + artifactPath, + snapshot_id: 'snapshot-1', + }, + }, + }, + ], + }, + ], + } + + expect(containsStructuralAuditReceipt(collision, 'snapshot-1')).toBe(true) + // Same expectedSnapshotId rule as structuralReceipt: with no expected id, + // any string snapshot_id counts. + expect(containsStructuralAuditReceipt(collision)).toBe(true) + expect(containsStructuralAuditReceipt(collision, '')).toBe(true) + }) + + it('rejects an already-persisted marker bound to a different snapshot', () => { + // Keeping the snapshot binding on the marker is what stops any colliding + // write from forging coverage for a snapshot it never evaluated. + const collision = { + alreadyPersisted: { + schema_version: 1, + shardId: 'services', + artifactPath: '.agents/sessions/readiness/findings/services.md', + snapshot_id: 'snapshot-1', + }, + } + + expect(containsStructuralAuditReceipt(collision, 'snapshot-2')).toBe(false) + }) + + it('rejects an already-persisted marker with no snapshot binding when one is expected', () => { + // A legacy call without snapshotId gets an unbound marker, so it must not + // satisfy a snapshot-bound gate. + const unbound = { + alreadyPersisted: { + schema_version: 1, + shardId: 'services', + artifactPath: '.agents/sessions/readiness/findings/services.md', + }, + } + + expect(containsStructuralAuditReceipt(unbound, 'snapshot-1')).toBe(false) + expect(containsStructuralAuditReceipt(unbound)).toBe(false) + }) + + it('rejects an ordinary rejection that carries no already-persisted marker', () => { + // Only the durably-persisted case satisfies the gate; a plain failed write + // must never clear it. + expect( + containsStructuralAuditReceipt( + { + messageHistory: [ + { + role: 'tool', + content: [ + { + type: 'json', + value: { + artifactPath: + '.agents/sessions/readiness/findings/services.md', + errorMessage: + 'Audit findings artifact was not confirmed as written.', + }, + }, + ], + }, + ], + }, + 'snapshot-1', + ), + ).toBe(false) + }) }) diff --git a/common/src/util/__tests__/job-registry.test.ts b/common/src/util/__tests__/job-registry.test.ts index b473969e96..cb974d7727 100644 --- a/common/src/util/__tests__/job-registry.test.ts +++ b/common/src/util/__tests__/job-registry.test.ts @@ -328,6 +328,60 @@ describe('jobRegistry', () => { }) }) + describe('terminal result stamping', () => { + it('stamps job.result from a terminal lifecycle event', () => { + const job = createRunningJob() + + jobRegistry.emit(job.jobId, { + type: 'lifecycle', + state: 'completed', + result: { output: 'done' }, + }) + + expect(jobRegistry.get(job.jobId)?.state).toBe('completed') + expect(jobRegistry.get(job.jobId)?.result).toEqual({ output: 'done' }) + }) + + it('ignores a result carried by a non-terminal transition', () => { + const job = createRunningJob() + + jobRegistry.emit(job.jobId, { + type: 'lifecycle', + state: 'stopping', + result: 'not-settled-yet', + }) + + expect(jobRegistry.get(job.jobId)?.state).toBe('stopping') + expect(jobRegistry.get(job.jobId)?.result).toBeUndefined() + }) + + it('does not stamp a result when the terminal transition is rejected', () => { + const job = createRunningJob() + jobRegistry.cancel(job.jobId) + + // Terminal states are absorbing, so a late completed(result) is not + // recorded at all: a cancelled job never acquires a result. + const rejected = jobRegistry.emit(job.jobId, { + type: 'lifecycle', + state: 'completed', + result: { output: 'too late' }, + }) + + expect(rejected).toBeUndefined() + expect(jobRegistry.get(job.jobId)?.state).toBe('cancelled') + expect(jobRegistry.get(job.jobId)?.result).toBeUndefined() + }) + + it('leaves result undefined when a terminal event carries none', () => { + const job = createRunningJob() + + jobRegistry.emit(job.jobId, lifecycle('completed')) + + expect(jobRegistry.get(job.jobId)?.state).toBe('completed') + expect(jobRegistry.get(job.jobId)?.result).toBeUndefined() + }) + }) + describe('event sequencing', () => { it('assigns contiguous, monotonically increasing sequence numbers per job', () => { const job = createRunningJob() @@ -674,6 +728,20 @@ describe('jobRegistry', () => { 'three', ]) }) + + it('clamps a cursor past the latest sequence back down to it', () => { + const job = createRunningJob() + jobRegistry.emit(job.jobId, out('only')) + const latest = jobRegistry.snapshot(job.jobId, 0)!.events.at(-1)!.sequence + + const snap = jobRegistry.snapshot(job.jobId, 10_000)! + + // An echoed-back bogus cursor is self-healed instead of pinning the + // consumer past every future event. + expect(snap.events).toEqual([]) + expect(snap.nextCursor).toBe(latest) + expect(snap.truncated).toBe(false) + }) }) describe('wait', () => { @@ -816,6 +884,61 @@ describe('jobRegistry', () => { // wait()'s unknown-job-id resolution instead of hanging forever. expect(await pending).toBeUndefined() }) + + it('clamps a cursor past the latest sequence so the terminal transition settles the wait', async () => { + const job = createRunningJob() + + // Unclamped, this cursor makes every later event fail `sequence > cursor`, + // so the waiter could only ever settle by timing out. + const pending = jobRegistry.wait(job.jobId, { + cursor: 10_000, + predicate: () => false, + timeoutMs: 5_000, + }) + await sleep(5) + + jobRegistry.emit(job.jobId, lifecycle('completed')) + + const result = (await pending)! + expect(result.timedOut).toBeFalsy() + expect(result.state).toBe('completed') + }) + + it('settles a pending wait when the abort signal fires', async () => { + const job = createRunningJob() + const controller = new AbortController() + + const pending = jobRegistry.wait(job.jobId, { + predicate: () => false, + timeoutMs: 5_000, + signal: controller.signal, + }) + await sleep(5) + controller.abort() + + const result = (await pending)! + expect(result.timedOut).toBe(true) + expect(result.state).toBe('running') + // Aborting the join does not touch the job, and the settled waiter is + // detached so later events cannot resolve it again. + jobRegistry.emit(job.jobId, out('after-abort')) + expect(jobRegistry.get(job.jobId)?.state).toBe('running') + }) + + it('resolves immediately for an already-aborted signal', async () => { + const job = createRunningJob() + const controller = new AbortController() + controller.abort() + + const result = (await jobRegistry.wait(job.jobId, { + predicate: () => false, + timeoutMs: 5_000, + signal: controller.signal, + }))! + + expect(result.timedOut).toBe(true) + expect(result.state).toBe('running') + }) }) describe('stream', () => { diff --git a/common/src/util/audit-receipt.ts b/common/src/util/audit-receipt.ts index 56715089ef..eb331a4523 100644 --- a/common/src/util/audit-receipt.ts +++ b/common/src/util/audit-receipt.ts @@ -1,9 +1,19 @@ const MAX_TRAVERSAL_DEPTH = 32 /** - * Recursively searches an arbitrary value for a nested `structuralReceipt` - * object whose `snapshot_id` matches `expectedSnapshotId`. When - * `expectedSnapshotId` is empty/undefined, any structuralReceipt with a string + * Recursively searches an arbitrary value for nested proof that an audit + * shard's findings are durably persisted for `expectedSnapshotId`. Two markers + * count, and both are held to the SAME snapshot binding: + * - `structuralReceipt` from a successful write_audit_findings call, and + * - `alreadyPersisted` from an already-exists write_audit_findings collision + * whose on-disk artifact is byte-identical to that call's rendered findings + * (that call wrote nothing, so it carries this marker in place of a + * synthesized `structuralReceipt`). The marker carries + * `snapshot_id` only when those identical persisted findings were rendered + * for that snapshot, so neither a stale artifact from a different snapshot + * nor a collision whose contents differ can clear a snapshot-bound gate. No + * other rejection satisfies the gate. + * When `expectedSnapshotId` is empty/undefined, any marker with a string * `snapshot_id` counts as a match. Depth-bounded (<=32) to stay conservative * on deeply nested inputs, with min-depth revisit tracking for cyclic/shared * graphs: each object records the shallowest depth it was reached at, so a @@ -18,6 +28,18 @@ export function containsStructuralAuditReceipt( ): boolean { let found = false const visited = new WeakMap() + // A marker counts only when its own snapshot_id matches, so a colliding + // write can never claim coverage for a snapshot it did not attest to. + const bindsExpectedSnapshot = (marker: unknown): boolean => { + if (!marker || typeof marker !== 'object' || Array.isArray(marker)) { + return false + } + const snapshotId = (marker as Record).snapshot_id + return ( + typeof snapshotId === 'string' && + (!expectedSnapshotId || snapshotId === expectedSnapshotId) + ) + } const visit = (item: unknown, depth = 0): void => { if ( found || @@ -35,16 +57,12 @@ export function containsStructuralAuditReceipt( return } const record = item as Record - const receipt = record.structuralReceipt - if (receipt && typeof receipt === 'object' && !Array.isArray(receipt)) { - const snapshotId = (receipt as Record).snapshot_id - if ( - typeof snapshotId === 'string' && - (!expectedSnapshotId || snapshotId === expectedSnapshotId) - ) { - found = true - return - } + if ( + bindsExpectedSnapshot(record.structuralReceipt) || + bindsExpectedSnapshot(record.alreadyPersisted) + ) { + found = true + return } for (const nested of Object.values(record)) visit(nested, depth + 1) } diff --git a/common/src/util/job-registry.ts b/common/src/util/job-registry.ts index 2ac30572d4..920f6dec67 100644 --- a/common/src/util/job-registry.ts +++ b/common/src/util/job-registry.ts @@ -91,6 +91,13 @@ export type JobEventPayload = state: JobState exitCode?: number | null error?: string + /** + * Resolved value of the unit of work. Stamped onto `job.result` when the + * transition is terminal, exactly like `exitCode`/`error`, so the record + * itself is the durable home of a settled result and an adapter's own + * view no longer has to stay alive for the result to be readable. + */ + result?: unknown } | { type: 'status'; message?: string } @@ -164,8 +171,19 @@ export interface WaitJobOptions { predicate?: (event: JobEvent) => boolean /** Resolve with timedOut=true after this many milliseconds. */ timeoutMs?: number - /** Only events with sequence > cursor count as new. Defaults to 0. */ + /** + * Only events with sequence > cursor count as new. Defaults to 0. Clamped to + * the job's latest sequence, so a cursor past the end can never make the + * terminal transition unable to settle the waiter. + */ cursor?: number + /** + * Abort the wait when this signal fires. The waiter settles with the job's + * current snapshot and `timedOut: true` (an aborted join observed no terminal + * transition) and its listener is detached on settle, so joining a job can + * never outlive its caller. + */ + signal?: AbortSignal } /** Result of {@link JobRegistry.wait}. */ @@ -333,6 +351,14 @@ interface PendingWaiter { predicate?: (event: JobEvent) => boolean timeoutMs?: number timer?: ReturnType + /** + * Caller-supplied abort signal and the listener attached to it. Both are + * detached together with the timer on settle (see + * {@link JobRegistry.detachWaiter}) so a settled waiter leaves nothing + * attached to a long-lived signal. + */ + signal?: AbortSignal + onAbort?: () => void /** * Accepts `undefined` because {@link JobRegistry.clear} resolves pending * waiters with it — the same "job no longer exists" value {@link @@ -508,30 +534,35 @@ export class JobRegistry { } /** - * Return every buffered event with sequence > cursor. `nextCursor` is the - * highest sequence returned (or the passed cursor when none); `truncated` - * reports that events at or below the cursor were evicted, and `dropped` - * is the cumulative eviction count so consumers can detect gaps. + * Return every buffered event with sequence > cursor. The cursor is + * normalized by {@link JobRegistry.clampCursor} first, so a consumer that + * reports back a cursor past the job's latest sequence is self-healed instead + * of being pinned past every future event. `nextCursor` is the highest + * sequence returned (or the clamped cursor when none); `truncated` reports + * that events at or below the cursor were evicted, and `dropped` is the + * cumulative eviction count so consumers can detect gaps. */ snapshot(jobId: string, cursor = 0): JobSnapshot | undefined { const record = this.records.get(jobId) if (!record) return undefined - const events = record.events.filter((event) => event.sequence > cursor) + const clamped = this.clampCursor(record, cursor) + const events = record.events.filter((event) => event.sequence > clamped) const last = events[events.length - 1] return { events, - nextCursor: last ? last.sequence : cursor, + nextCursor: last ? last.sequence : clamped, state: record.job.state, - truncated: this.truncatedAtCursor(record, cursor), + truncated: this.truncatedAtCursor(record, clamped), dropped: record.dropped, } } /** * Resolve when the predicate matches a NEW event (sequence > cursor), or - * the job reaches a terminal state, or the timeout fires — whichever comes - * first. Driven purely off the registry's internal notifications: no - * sleep-polling, and listeners/timers are cleaned up on settle. Resolves + * the job reaches a terminal state, or the timeout fires, or the caller's + * abort signal fires — whichever comes first. Driven purely off the + * registry's internal notifications: no sleep-polling, and + * listeners/timers/abort listeners are cleaned up on settle. Resolves * `undefined` for an unknown job id. */ wait( @@ -541,8 +572,16 @@ export class JobRegistry { const record = this.records.get(jobId) if (!record) return Promise.resolve(undefined) - const cursor = options.cursor ?? 0 - const immediate = this.evaluateWait(record, cursor, options.predicate) + // Clamp before anything else: a cursor past the job's latest sequence would + // make every later event — the terminal transition included — fail the + // `sequence > cursor` test, so the waiter could never be settled. + const cursor = this.clampCursor(record, options.cursor) + const immediate = this.evaluateWait( + record, + cursor, + options.predicate, + options.signal?.aborted === true, + ) if (immediate) return Promise.resolve(immediate) return new Promise((resolve) => { @@ -557,6 +596,16 @@ export class JobRegistry { this.settleWaiter(jobId, waiter) }, options.timeoutMs) } + if (options.signal) { + // Settle (and detach) on abort so a join cannot outlive its caller. An + // aborted join observed no terminal transition, so it settles with the + // current snapshot and timedOut=true, exactly like a deadline. + waiter.signal = options.signal + waiter.onAbort = () => { + this.settleWaiter(jobId, waiter, true) + } + options.signal.addEventListener('abort', waiter.onAbort, { once: true }) + } this.addWaiter(jobId, waiter) }) } @@ -680,7 +729,7 @@ export class JobRegistry { ]) this.waiters.clear() for (const waiter of pendingWaiters) { - if (waiter.timer) clearTimeout(waiter.timer) + this.detachWaiter(waiter) waiter.resolve(undefined) } for (const subscribers of this.streamSubscribers.values()) { @@ -774,6 +823,9 @@ export class JobRegistry { if (payload.error !== undefined) { record.job.error = payload.error } + if (payload.result !== undefined) { + record.job.result = payload.result + } } record.job.state = transitionedTo } @@ -835,6 +887,28 @@ export class JobRegistry { return first !== undefined && first.sequence > cursor + 1 } + /** + * Normalize a consumer-supplied cursor: floor a missing/non-finite/negative + * value to 0, and clamp anything above the job's latest sequence back down to + * it. Without the upper clamp a cursor past the end would make every future + * event (including the terminal transition) fail the `sequence > cursor` + * test, so a waiter created with it could never settle and a snapshot would + * keep echoing the bogus cursor back to the consumer. + */ + private clampCursor(record: JobRecord, cursor: number | undefined): number { + const latestSequence = record.nextSequence - 1 + if (cursor === undefined || !Number.isFinite(cursor) || cursor < 0) return 0 + return Math.min(Math.floor(cursor), latestSequence) + } + + /** Clear a waiter's timeout and detach its abort listener. */ + private detachWaiter(waiter: PendingWaiter): void { + if (waiter.timer) clearTimeout(waiter.timer) + if (waiter.signal && waiter.onAbort) { + waiter.signal.removeEventListener('abort', waiter.onAbort) + } + } + /** * Evaluate a wait against the job's CURRENT buffer: predicate over new * events first, then terminal state. Returns the result when the wait can @@ -892,7 +966,7 @@ export class JobRegistry { if (!waiters || !waiters.has(waiter)) return waiters.delete(waiter) if (waiters.size === 0) this.waiters.delete(jobId) - if (waiter.timer) clearTimeout(waiter.timer) + this.detachWaiter(waiter) const record = this.records.get(jobId) if (!record) { @@ -915,8 +989,8 @@ export class JobRegistry { waiter.resolve(result) return } - // The record is live and non-terminal (only reachable from the timeout - // path): report the current snapshot with timedOut=true. + // The record is live and non-terminal (only reachable from the timeout or + // abort path): report the current snapshot with timedOut=true. waiter.resolve( this.evaluateWait(record, waiter.cursor, waiter.predicate, true) ?? { events: [], diff --git a/docs/agents-and-tools.md b/docs/agents-and-tools.md index 681d6c4068..bb384f20c8 100644 --- a/docs/agents-and-tools.md +++ b/docs/agents-and-tools.md @@ -16,11 +16,11 @@ Agents in Openbuff can be either prompt-based or programmatic (utilizing `handle Not every shipped agent is directly spawnable by the orchestrator (`base2` / `base-deep`). Agents fall into two categories: -**Orchestrator-spawnable agents** are listed in the `spawnableAgents` array of `base2.ts` and `base-deep.ts`. These are general-purpose specialists the orchestrator can delegate to at policy-defined phase boundaries: `file-picker`, `code-searcher`, `code-reviewer`, `editor`, `thinker`, `basher`, `researcher-web`, `researcher-docs`, `git-committer`, `debugger`, `doc-writer`, `security-reviewer`, `test-writer`, `librarian`, and others. `context-pruner` is runtime-internal and is not publicly spawnable. Adding an agent to `spawnableAgents` means the orchestrator may spawn it when the current phase and task scope make its capabilities relevant; it does not mean agents should be spawned randomly or for tiny direct-answer tasks. +**Orchestrator-spawnable agents** are listed in the `spawnableAgents` array of `base2.ts` and `base-deep.ts`. These are general-purpose specialists the orchestrator can delegate to at policy-defined phase boundaries: `file-picker`, `code-reviewer`, `editor`, `thinker`, `basher`, `researcher-web`, `researcher-docs`, `git-committer`, `debugger`, `doc-writer`, `security-reviewer`, `test-writer`, `librarian`, and others. `context-pruner` is runtime-internal and is not publicly spawnable. Adding an agent to `spawnableAgents` means the orchestrator may spawn it when the current phase and task scope make its capabilities relevant; it does not mean agents should be spawned randomly or for tiny direct-answer tasks. Common phase triggers and routing policies: -- `file-picker`, `code-searcher`, `researcher-web`, `researcher-docs` — discovery phase when files, APIs, docs, or commands are not already obvious. Scope first as `tiny`, `focused`, `multi-file`, `cross-subsystem`, or `unknown surface`; scale reads/searches and parallel shards accordingly. For large-repo planning, do not use file-pickers as the only shards: they are discovery-focused and should be paired with code-searchers plus reasoning-capable shards when analysis is required. +- `file-picker`, `researcher-web`, `researcher-docs` — discovery phase when files, APIs, docs, or commands are not already obvious. Scope first as `tiny`, `focused`, `multi-file`, `cross-subsystem`, or `unknown surface`; scale reads/searches and parallel shards accordingly. For large-repo planning, do not use file-pickers as the only shards: they are discovery-focused and should be paired with direct code_search plus reasoning-capable general-agent shards when analysis is required. - `general-agent` — focused reasoning/audit shards after discovery for larger repositories or complex domains. Give each shard explicit files or a narrow subsystem. Audit shards persist their own structured results with `write_audit_findings` and return only a compact artifact receipt, so the parent context carries paths/counts/hashes instead of every finding body. - `thinker` — reasoning phase after context gathering for complex design, architecture, tradeoff, risk, spec/plan critique, or debugging strategy choices. Use it to synthesize discovered evidence when no file writes are needed; skip it for straightforward edits and never use it as a replacement for reading files. - `editor` — implementation phase for non-trivial source changes, with a self-contained implementation brief because it does not rely on parent context. The five brief fields accept either colon labels (`Requirements:`) or normal Markdown headings (`## Requirements`). Skip it for tiny one-file edits and direct answers. @@ -150,7 +150,7 @@ GLB, and OBJ inspectors do not require Blender. Several shipped agents share prompt text through centralized sections rather than maintaining separate copies: - `agents/base2/quality-prompt-section.ts` exports the shared Code Craftsmanship guidance used by `base2`, `base-deep`, and the `editor` agent. This section is byte-frozen by snapshot tests so the three consumers do not drift accidentally. -- The same file also exports `buildBroadAuditSection(finalizeClause)`, which injects the orchestrator's scope-then-shard contract for broad, open-ended, and audit-style requests. The generated section tells `base2` / `base-deep` to measure repository breadth before synthesis, cover frontend/page/route/UI wiring when a frontend exists, spawn file-picker and code-searcher shards by subsystem, add general-agent reasoning/audit shards that can write durable findings files for whole-codebase or production-readiness audits, use thinker for post-discovery synthesis when useful, and interpolate `finalizeClause` for the current prompt path. +- The same file also exports `buildBroadAuditSection(finalizeClause)`, which injects the orchestrator's scope-then-shard contract for broad, open-ended, and audit-style requests. The generated section tells `base2` / `base-deep` to measure repository breadth before synthesis, cover frontend/page/route/UI wiring when a frontend exists, spawn file-picker discovery shards by subsystem, add general-agent reasoning/audit shards that can write durable findings files for whole-codebase or production-readiness audits, use thinker for post-discovery synthesis when useful, and interpolate `finalizeClause` for the current prompt path. - The same file also exports orchestrator-only guidance for gate awareness, security-sensitive file review, and git discipline. `base2` and `base-deep` interpolate those sections; the `editor` intentionally does not, because validation/review, security triage, and git workflow orchestration remain parent-agent responsibilities. - `common/src/constants/prompt-sections.ts` owns the shared Frontend Development section. `packages/agent-runtime/src/templates/types.ts` exposes it as the `{CODEBUFF_FRONTEND_SECTION}` placeholder, and `packages/agent-runtime/src/templates/strings.ts` replaces that placeholder only when `fileTreeHasFrontendFiles` detects `.tsx` or `.jsx` files in the project tree. - `common/src/util/language-capabilities.ts` is the canonical registry for TypeScript/JavaScript, Python, Rust, Go, Java, C#/.NET, C/C++, Ruby, PHP, Swift, Kotlin, and GDScript. It owns extensions, manifests, bundled idioms, language-server/compiler/formatter/linter/test metadata, and focused/project validation stages. `common/src/util/language-profiles.ts` derives `{CODEBUFF_LANGUAGE_PROFILE}` detection from that registry. @@ -700,19 +700,18 @@ fails closed, changes nothing, and returns a diagnostic instead of executing the tool. Codebase search for orchestrator/base agents (`base2` / `base-deep`) and the -`general-agent` discovery/analysis shard may use `code_search` directly for -single-pattern content search, -`query_index` for graph/index retrieval, or spawn the `code-searcher` agent for -multi-query batch search with `params.searchQueries`. Ungranted tools still -fail closed. +`general-agent` discovery/analysis shard uses `code_search` for ripgrep-style +content search and `query_index` for graph/index retrieval. Several patterns +mean several `code_search` calls, which may be issued in parallel in one +message. Ungranted tools still fail closed. The rejection message names the tools the agent actually has available. When the attempted name is a real-but-ungranted registry tool, the message says so; for a likely typo it also suggests a near lexical match ("Did you mean ..."). When you hit this error, pick a tool from the listed available tools, or spawn -an agent that provides the capability (for example, spawn `code-searcher` for -multi-query batch search). Do not retry the same unavailable name — the result -will not change. +an agent that provides the capability (for example, call `code_search` directly +for ripgrep-style content search, one call per pattern). Do not retry the same +unavailable name — the result will not change. ### `suggest_followups` last-action contract diff --git a/docs/architecture.md b/docs/architecture.md index 00da719f1a..5767e70dc1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,7 +86,7 @@ Prompt-based and programmatic agent definitions that ship with Openbuff. - **Key agents:** - `base2/` — The default agent family (base2, base2-plan). - `editor/` — Code editing specialist. - - `file-explorer/` — File picker, code searcher, directory lister, glob matcher. + - `file-explorer/` — File picker, file lister, directory lister, glob matcher. - `thinker/` — Deep reasoning agent. - `reviewer/` — Code review agent. - `researcher/` — Local web search and docs search agents. diff --git a/docs/deterministic-edit-system.md b/docs/deterministic-edit-system.md index a76dbdece1..b4a1c230f2 100644 --- a/docs/deterministic-edit-system.md +++ b/docs/deterministic-edit-system.md @@ -481,8 +481,8 @@ Subagent use is phase-triggered orchestration policy, not a random choice. The p `multi-file`, `cross-subsystem`, or `unknown surface` before editing. Tiny tasks read the directly relevant file; focused tasks also inspect adjacent tests/callers; multi-file tasks search and read representative - files; cross-subsystem or unknown-surface tasks use `query_index`, - `list_directory`, `glob`, and parallel file-picker/code-searcher shards. + cross-subsystem or unknown-surface tasks use `query_index`, + `list_directory`, `glob`, and parallel file-picker shards. - Tool choice: route repository state to `git_status`, source inspection to `read_files`/`read_outline`/`read_subtree`/`glob`/`list_directory`/ `query_index`, images to `read_image`, whole-symbol edits to @@ -495,8 +495,8 @@ Subagent use is phase-triggered orchestration policy, not a random choice. The p migrations, release/publish/deploy actions, production-affecting scripts, and ambiguous product behavior. For reversible or obvious choices, choose the conservative path and proceed. -- Discovery phase: use `query_index` directly, then spawn file-picker, - code-searcher, or researcher agents when relevant files, APIs, or +- Discovery phase: use `query_index` directly, then spawn file-picker or + researcher agents when relevant files, APIs, or commands are not already obvious. - Reasoning phase: spawn `thinker` after context discovery for complex design, architecture, risk, tradeoff, spec/plan critique, or debugging diff --git a/docs/local-mode.md b/docs/local-mode.md index 97049401d2..30148edfc8 100644 --- a/docs/local-mode.md +++ b/docs/local-mode.md @@ -114,8 +114,7 @@ ChatGPT/Codex subscription: }, "agents": { "base2": "openai/gpt-5.5", - "thinker": "codex/gpt-5.5", - "code-searcher": "local/qwen-coder" + "thinker": "codex/gpt-5.5" }, "providers": { "openai": { diff --git a/docs/testing.md b/docs/testing.md index e252967d22..7a7903c8f9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -70,7 +70,7 @@ SESSION=$(./scripts/tmux/tmux-cli.sh start \ ./scripts/tmux/tmux-cli.sh stop "$SESSION" ``` -When verifying UI output, prefer checking the saved capture file for concrete strings that should and should not appear. For example, after expanding a code-searcher agent, check that the capture shows the search summary but not raw structured payload keys like `results:` or `stdout:`. +When verifying UI output, prefer checking the saved capture file for concrete strings that should and should not appear. For example, after expanding a file-picker agent, check that the capture shows the search summary but not raw structured payload keys like `results:` or `stdout:`. ## Deterministic lifecycle E2E coverage diff --git a/evals/buffbench/__tests__/plan-sharding-signals.test.ts b/evals/buffbench/__tests__/plan-sharding-signals.test.ts index 533b9202e7..14ffb459eb 100644 --- a/evals/buffbench/__tests__/plan-sharding-signals.test.ts +++ b/evals/buffbench/__tests__/plan-sharding-signals.test.ts @@ -269,13 +269,13 @@ describe('extractSpawnAgentsCalls', () => { const events: PrintModeEvent[] = [ spawnAgentsCall([ { agent_type: 'file-picker', prompt: 'a' }, - { agent_type: 'code-searcher', prompt: 'b' }, + { agent_type: 'general-agent', prompt: 'b' }, ]), ] const calls = extractSpawnAgentsCalls(events) expect(calls).toHaveLength(1) expect(calls[0].agentCount).toBe(2) - expect(calls[0].agentTypes).toEqual(['file-picker', 'code-searcher']) + expect(calls[0].agentTypes).toEqual(['file-picker', 'general-agent']) }) test('ignores nested spawn_agents calls (parentAgentId set)', () => { @@ -313,7 +313,7 @@ describe('extractSpawnAgentsCalls', () => { const events: PrintModeEvent[] = [ spawnAgentsCall([{ agent_type: 'file-picker', prompt: 'a' }]), spawnAgentsCall([ - { agent_type: 'code-searcher', prompt: 'b' }, + { agent_type: 'general-agent', prompt: 'b' }, { agent_type: 'researcher-docs', prompt: 'c' }, ]), ] @@ -332,7 +332,7 @@ describe('extractSubagentStarts', () => { test('extracts top-level subagent_start records', () => { const events: PrintModeEvent[] = [ subagentStart({ agentId: 's1', agentType: 'file-picker' }), - subagentStart({ agentId: 's2', agentType: 'code-searcher' }), + subagentStart({ agentId: 's2', agentType: 'general-agent' }), ] const starts = extractSubagentStarts(events) expect(starts).toHaveLength(2) @@ -385,7 +385,7 @@ describe('computePlanShardingSignals', () => { test('detects parallel sharding via concurrent subagent starts', () => { const events: PrintModeEvent[] = [ subagentStart({ agentId: 's1', agentType: 'file-picker' }), - subagentStart({ agentId: 's2', agentType: 'code-searcher' }), + subagentStart({ agentId: 's2', agentType: 'general-agent' }), subagentStart({ agentId: 's3', agentType: 'file-picker' }), subagentFinish({ agentId: 's1' }), subagentFinish({ agentId: 's2' }), @@ -403,7 +403,7 @@ describe('computePlanShardingSignals', () => { spawnAgentsCall([ { agent_type: 'file-picker', prompt: 'a' }, { agent_type: 'file-picker', prompt: 'b' }, - { agent_type: 'code-searcher', prompt: 'c' }, + { agent_type: 'general-agent', prompt: 'c' }, { agent_type: 'researcher-docs', prompt: 'd' }, ]), ] @@ -413,12 +413,12 @@ describe('computePlanShardingSignals', () => { expect(s.requestedAgentTypes).toEqual([ 'file-picker', 'file-picker', - 'code-searcher', + 'general-agent', 'researcher-docs', ]) expect(s.distinctAgentTypes).toEqual([ - 'code-searcher', 'file-picker', + 'general-agent', 'researcher-docs', ]) }) @@ -449,7 +449,7 @@ describe('evaluateShardingVerdict', () => { test('pass: parallel subagent sharding for an audit prompt', () => { const events: PrintModeEvent[] = [ subagentStart({ agentId: 's1', agentType: 'file-picker' }), - subagentStart({ agentId: 's2', agentType: 'code-searcher' }), + subagentStart({ agentId: 's2', agentType: 'general-agent' }), subagentFinish({ agentId: 's1' }), subagentFinish({ agentId: 's2' }), ] @@ -464,7 +464,7 @@ describe('evaluateShardingVerdict', () => { spawnAgentsCall([ { agent_type: 'file-picker', prompt: 'a' }, { agent_type: 'file-picker', prompt: 'b' }, - { agent_type: 'code-searcher', prompt: 'c' }, + { agent_type: 'general-agent', prompt: 'c' }, ]), ] const signals = computePlanShardingSignals({ events, prompt: AUDIT_PROMPT }) @@ -546,20 +546,20 @@ describe('evaluateShardingVerdict', () => { /** * Build a sharding trace with `filePickers` file-picker subagent_start events - * and `codeSearchers` code-searcher subagent_start events (no finishes → all - * in-flight, so they count toward the sharding signals). + * and `auditShards` general-agent audit-shard subagent_start events (no + * finishes → all in-flight, so they count toward the sharding signals). */ function shardingEvents( filePickers: number, - codeSearchers: number, + auditShards: number, ): PrintModeEvent[] { const events: PrintModeEvent[] = [] for (let i = 0; i < filePickers; i++) { events.push(subagentStart({ agentId: `fp-${i}`, agentType: 'file-picker' })) } - for (let i = 0; i < codeSearchers; i++) { + for (let i = 0; i < auditShards; i++) { events.push( - subagentStart({ agentId: `cs-${i}`, agentType: 'code-searcher' }), + subagentStart({ agentId: `audit-${i}`, agentType: 'general-agent' }), ) } return events @@ -588,7 +588,7 @@ describe('evaluateMinimumShardRule', () => { expect(result.requiredPairs).toBe(5) expect(result.actualPairs).toBe(5) expect(result.filePickerCount).toBe(5) - expect(result.codeSearcherCount).toBe(5) + expect(result.auditShardCount).toBe(5) expect(result.satisfies).toBe(true) expect(result.reason).toContain('>=5 shard pairs') }) @@ -618,7 +618,7 @@ describe('evaluateMinimumShardRule', () => { expect(result.requiredPairs).toBe(5) expect(result.actualPairs).toBe(2) expect(result.filePickerCount).toBe(2) - expect(result.codeSearcherCount).toBe(2) + expect(result.auditShardCount).toBe(2) expect(result.satisfies).toBe(false) expect(result.reason).toContain('only 2') }) @@ -632,8 +632,8 @@ describe('evaluateMinimumShardRule', () => { prompt: `file shard ${index}`, })), ...Array.from({ length: 5 }, (_, index) => ({ - agent_type: 'code-searcher', - prompt: `search shard ${index}`, + agent_type: 'general-agent', + prompt: `audit shard ${index}`, })), ]), ] @@ -643,12 +643,12 @@ describe('evaluateMinimumShardRule', () => { }) const result = evaluateMinimumShardRule({ signals, breadth }) expect(signals.filePickerCount).toBe(5) - expect(signals.codeSearcherCount).toBe(5) + expect(signals.auditShardCount).toBe(5) expect(result.actualPairs).toBe(5) expect(result.satisfies).toBe(true) }) - test('violates: has file-pickers but no code-searchers (actualPairs=0)', () => { + test('violates: has file-pickers but no general-agent audit shards (actualPairs=0)', () => { const breadth = classifyBreadth(BROAD_AUDIT_3_DOMAINS) const signals = computePlanShardingSignals({ events: shardingEvents(5, 0), @@ -656,13 +656,13 @@ describe('evaluateMinimumShardRule', () => { }) const result = evaluateMinimumShardRule({ signals, breadth }) expect(result.filePickerCount).toBe(5) - expect(result.codeSearcherCount).toBe(0) + expect(result.auditShardCount).toBe(0) expect(result.actualPairs).toBe(0) expect(result.satisfies).toBe(false) - expect(result.reason).toContain('code-searcher=0') + expect(result.reason).toContain('general-agent=0') }) - test('violates: has code-searchers but no file-pickers (actualPairs=0)', () => { + test('violates: has general-agent audit shards but no file-pickers (actualPairs=0)', () => { const breadth = classifyBreadth(BROAD_AUDIT_3_DOMAINS) const signals = computePlanShardingSignals({ events: shardingEvents(0, 5), @@ -670,7 +670,7 @@ describe('evaluateMinimumShardRule', () => { }) const result = evaluateMinimumShardRule({ signals, breadth }) expect(result.filePickerCount).toBe(0) - expect(result.codeSearcherCount).toBe(5) + expect(result.auditShardCount).toBe(5) expect(result.actualPairs).toBe(0) expect(result.satisfies).toBe(false) expect(result.reason).toContain('file-picker=0') diff --git a/evals/buffbench/__tests__/run-buffbench.test.ts b/evals/buffbench/__tests__/run-buffbench.test.ts index 07c06759b7..26715aaf37 100644 --- a/evals/buffbench/__tests__/run-buffbench.test.ts +++ b/evals/buffbench/__tests__/run-buffbench.test.ts @@ -156,7 +156,6 @@ describe('generateEvalTask', () => { 'find-all-referencer', 'file-picker', 'file-lister', - 'code-searcher', 'directory-lister', 'glob-matcher', ]), diff --git a/evals/buffbench/eval-task-generator.ts b/evals/buffbench/eval-task-generator.ts index 7dea4a80e4..c104bcdb85 100644 --- a/evals/buffbench/eval-task-generator.ts +++ b/evals/buffbench/eval-task-generator.ts @@ -1,7 +1,6 @@ import { type AgentDefinition, type OpenbuffClient } from '@openbuff/sdk' import { PLACEHOLDER } from '../../agents/types/secret-agent-definition' -import codeSearcherDef from '../../agents/file-explorer/code-searcher' import directoryListerDef from '../../agents/file-explorer/directory-lister' import fileListerDef from '../../agents/file-explorer/file-lister' import filePickerDef from '../../agents/file-explorer/file-picker' @@ -127,7 +126,6 @@ export async function generateEvalTask({ findAllReferencerDef as AgentDefinition, filePickerDef as AgentDefinition, fileListerDef as AgentDefinition, - codeSearcherDef as AgentDefinition, directoryListerDef as AgentDefinition, globMatcherDef as AgentDefinition, ...(agentDefinitions || []), diff --git a/evals/buffbench/plan-sharding-signals.ts b/evals/buffbench/plan-sharding-signals.ts index 92c48ffc07..7702bde0a4 100644 --- a/evals/buffbench/plan-sharding-signals.ts +++ b/evals/buffbench/plan-sharding-signals.ts @@ -5,8 +5,9 @@ * `buildPlanOnlyInstructionsPrompt` / `buildImplementationInstructionsPrompt` * in `agents/base2/base2.ts`. The guidance instructs the agent to, for * audit-style requests, first assess codebase scope and then shard parallel - * subagents (file-pickers + code-searchers, 3-6 for focused audits, 8-12 for - * whole-codebase audits) rather than doing a single surface-level codesearch. + * subagents (file-picker + general-agent audit shard pairs, 3-6 for focused + * audits, 8-12 for whole-codebase audits) rather than doing a single + * surface-level codesearch. * * This module is pure (no I/O, no side effects) so it is trivially * unit-testable, mirroring the design of `deterministic-signals.ts`. The @@ -83,12 +84,12 @@ export interface PlanShardingSignals { */ filePickerCount: number /** - * Number of `code-searcher` agents counted for the minimum-shard rule + * Number of `general-agent` audit shards counted for the minimum-shard rule * (M10.2): the larger of the `subagent_start` records of that type and 1 * if the type was requested via a `spawn_agents` call. See * `evaluateMinimumShardRule`. */ - codeSearcherCount: number + auditShardCount: number } /** Verdict for the plan-mode sharding eval. */ @@ -103,17 +104,17 @@ export interface ShardingEvaluation { /** * Result of evaluating the minimum-shard rule (M10.2, SPEC R10.2). A "pair" is - * one `file-picker` subagent + one `code-searcher` subagent. + * one `file-picker` DISCOVERY shard + one `general-agent` AUDIT shard. */ export interface MinimumShardEvaluation { /** Required shard pairs: `max(domainCount, 5)` for `broad-audit`, else 0. */ requiredPairs: number - /** Actual shard pairs: `min(filePickerCount, codeSearcherCount)`. */ + /** Actual shard pairs: `min(filePickerCount, auditShardCount)`. */ actualPairs: number /** Counted `file-picker` agents (see `evaluateMinimumShardRule`). */ filePickerCount: number - /** Counted `code-searcher` agents (see `evaluateMinimumShardRule`). */ - codeSearcherCount: number + /** Counted `general-agent` audit shards (see `evaluateMinimumShardRule`). */ + auditShardCount: number /** True iff `actualPairs >= requiredPairs`. */ satisfies: boolean /** Human-readable explanation. */ @@ -522,10 +523,10 @@ export function computePlanShardingSignals(params: { requestedAgentTypes, 'file-picker', ) - const codeSearcherCount = countAgentType( + const auditShardCount = countAgentType( subagentStarts, requestedAgentTypes, - 'code-searcher', + 'general-agent', ) const shardedParallely = peakConcurrency >= 2 @@ -547,16 +548,17 @@ export function computePlanShardingSignals(params: { topLevelDirectToolCount, promptKind: classifyPrompt(prompt), filePickerCount, - codeSearcherCount, + auditShardCount, } } /** * Minimum-shard rule evaluation (M10.2, SPEC R10.2). For a `broad-audit` * request the orchestrator must spawn at least `max(domainCount, 5)` shard - * pairs, where a pair = one `file-picker` subagent + one `code-searcher` - * subagent. For non-`broad-audit` breadth the rule is vacuously satisfied - * (`requiredPairs = 0`). + * pairs, where a pair = one `file-picker` DISCOVERY shard + one + * `general-agent` AUDIT shard (the audit shard is what emits + * `structuralReceipt` via `write_audit_findings`). For non-`broad-audit` + * breadth the rule is vacuously satisfied (`requiredPairs = 0`). * * Pure: no I/O, no side effects. Counts are derived from `signals` (which are * themselves derived purely from a trace) and `breadth` (derived purely from @@ -568,31 +570,31 @@ export function evaluateMinimumShardRule(params: { }): MinimumShardEvaluation { const { signals, breadth } = params const filePickerCount = signals.filePickerCount - const codeSearcherCount = signals.codeSearcherCount + const auditShardCount = signals.auditShardCount if (breadth.kind !== 'broad-audit') { return { requiredPairs: 0, actualPairs: 0, filePickerCount, - codeSearcherCount, + auditShardCount, satisfies: true, reason: 'minimum-shard rule only applies to broad-audit prompts', } } const requiredPairs = Math.max(breadth.domainCount, 5) - const actualPairs = Math.min(filePickerCount, codeSearcherCount) + const actualPairs = Math.min(filePickerCount, auditShardCount) const satisfies = actualPairs >= requiredPairs const reason = satisfies ? `>=${actualPairs} shard pairs (>=${requiredPairs} required) across ${breadth.domainCount} domains` - : `only ${actualPairs} shard pair(s) but ${requiredPairs} required (max(domainCount=${breadth.domainCount}, 5)); file-picker=${filePickerCount}, code-searcher=${codeSearcherCount}` + : `only ${actualPairs} shard pair(s) but ${requiredPairs} required (max(domainCount=${breadth.domainCount}, 5)); file-picker=${filePickerCount}, general-agent=${auditShardCount}` return { requiredPairs, actualPairs, filePickerCount, - codeSearcherCount, + auditShardCount, satisfies, reason, } @@ -649,7 +651,7 @@ export interface PlannerOutputCoverage { * - For `breadth.kind !== 'broad-audit'`: vacuously satisfied (empty matrix, * `allCovered = true`). * - For `broad-audit`: sort `breadth.domains` alphabetically, compute - * `actualPairs = min(signals.filePickerCount, signals.codeSearcherCount)`, + * `actualPairs = min(signals.filePickerCount, signals.auditShardCount)`, * then assign pairs round-robin (pair `i` goes to `domains[i % domainCount]`). * `covered` = `assignedPairs >= 1`. `uncoveredDomains` = entries with * `assignedPairs === 0` (happens when `actualPairs < domainCount`). @@ -673,10 +675,7 @@ export function buildCoverageMatrix(params: { return { entries: [], uncoveredDomains: [], allCovered: true } } - const actualPairs = Math.min( - signals.filePickerCount, - signals.codeSearcherCount, - ) + const actualPairs = Math.min(signals.filePickerCount, signals.auditShardCount) const assigned = new Array(domainCount).fill(0) for (let pair = 0; pair < actualPairs; pair++) { @@ -872,12 +871,13 @@ export function evaluateShardingVerdict( } // Minimum-shard rule (M10.2, SPEC R10.2): for `broad-audit` prompts the - // orchestrator must spawn >= max(domainCount, 5) (file-picker + code-searcher) - // pairs. This is an additional gate layered on top of the base sharding - // check. It only applies to `audit`-classified prompts (not `ambiguous`) and - // only when a prompt string is supplied so breadth can be classified — when - // `prompt` is omitted the check is skipped, preserving backward - // compatibility with the single-arg callers (e.g. `run-plan-sharding-eval.ts`). + // orchestrator must spawn >= max(domainCount, 5) (file-picker + general-agent + // audit shard) pairs. This is an additional gate layered on top of the base + // sharding check. It only applies to `audit`-classified prompts (not + // `ambiguous`) and only when a prompt string is supplied so breadth can be + // classified — when `prompt` is omitted the check is skipped, preserving + // backward compatibility with the single-arg callers (e.g. + // `run-plan-sharding-eval.ts`). if (signals.promptKind === 'audit' && prompt !== undefined) { const breadth = classifyBreadth(prompt) const minShard = evaluateMinimumShardRule({ signals, breadth }) diff --git a/evals/buffbench/run-plan-sharding-eval.ts b/evals/buffbench/run-plan-sharding-eval.ts index 85dd4141d5..9001d0d5ec 100644 --- a/evals/buffbench/run-plan-sharding-eval.ts +++ b/evals/buffbench/run-plan-sharding-eval.ts @@ -225,7 +225,7 @@ async function main() { topLevelDirectToolCount: signals.topLevelDirectToolCount, // M10.2 minimum-shard diagnostic counts. filePickerCount: signals.filePickerCount, - codeSearcherCount: signals.codeSearcherCount, + auditShardCount: signals.auditShardCount, }, // M10.3 coverage matrix (SPEC R10.3): per-domain shard assignment. coverageMatrix: { diff --git a/openbuff.d.example/routes.json b/openbuff.d.example/routes.json index 2f2572fa4b..4cbe8c63ec 100644 --- a/openbuff.d.example/routes.json +++ b/openbuff.d.example/routes.json @@ -24,7 +24,6 @@ "code-reviewer": "agentrouter/gpt-5.5", "file-picker": "agentrouter/gpt-5.5", "file-lister": "agentrouter/gpt-5.5", - "code-searcher": "agentrouter/gpt-5.5", "directory-lister": "agentrouter/gpt-5.5", "glob-matcher": "agentrouter/gpt-5.5", "context-pruner": "agentrouter/gpt-5.5", diff --git a/packages/agent-runtime/src/__tests__/background-agent-jobs.test.ts b/packages/agent-runtime/src/__tests__/background-agent-jobs.test.ts index a6beee331c..5e2747dc36 100644 --- a/packages/agent-runtime/src/__tests__/background-agent-jobs.test.ts +++ b/packages/agent-runtime/src/__tests__/background-agent-jobs.test.ts @@ -1,20 +1,116 @@ -import { describe, test, expect, beforeEach } from 'bun:test' +import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures/agent-runtime' +import { getInitialSessionState } from '@codebuff/common/types/session-state' +import { jobRegistry } from '@codebuff/common/util/job-registry' +import { assistantMessage } from '@codebuff/common/util/messages' +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from 'bun:test' +import { mockFileContext } from './test-utils' +import * as runAgentStep from '../run-agent-step' +import { + DEFAULT_CHECK_BACKGROUND_AGENT_FOLLOW_TIMEOUT_MS, + MAX_CHECK_BACKGROUND_AGENT_FOLLOW_TIMEOUT_MS, + handleCheckBackgroundAgent, + resolveCheckBackgroundAgentWaitBounds, +} from '../tools/handlers/tool/check-background-agent' +import { handleSpawnAgents } from '../tools/handlers/tool/spawn-agents' import { + abandonPreLaunchBackgroundAgentJob, allocateBackgroundAgentJob, + allocateBackgroundAgentJobBatch, assertBackgroundAgentCapacity, + assertBackgroundAgentJobOwned, attachBackgroundAgentPromise, registerBackgroundAgentJob, appendBackgroundAgentChunk, getBackgroundAgentJob, + getBackgroundAgentJobCore, + listRunningBackgroundAgentJobs, readNewBackgroundAgentChunks, readBackgroundAgentChunks, backgroundAgentJobOwnedBy, + backgroundAgentJobWasCancelled, + reconcileInterruptedBackgroundAgentIntents, takeDroppedBackgroundAgentChunkCount, cancelBackgroundAgentJob, __clearBackgroundAgentJobsForTest, } from '../util/background-agent-jobs' +import type { + BackgroundAgentJob, + BackgroundAgentJobOwner, +} from '../util/background-agent-jobs' +import type { AgentTemplate } from '@codebuff/common/types/agent-template' +import type { ParamsExcluding } from '@codebuff/common/types/function-params' + +const INTERRUPTED_INTENT_MESSAGE = + 'Background agent host process/session ended before a terminal receipt was recorded.' + +/** + * Attach an already-resolved coroutine and flush the two microtask ticks the + * settle chain needs (the `.then` handler, then the registry sync). + */ +async function settleBackgroundAgentJob( + job: BackgroundAgentJob, + result: unknown, +): Promise { + attachBackgroundAgentPromise(job, Promise.resolve(result)) + await Promise.resolve() + await Promise.resolve() +} + +/** + * Push the adapter past its 100-VIEW retention cap with SETTLED background + * agents so the count-cap sweep runs and evicts the oldest settled adapter + * views. Each filler is settled before the next is allocated, so the + * running-job quotas (32 total, 8 per root) are never in play. + */ +async function fillPastBackgroundAgentJobCountCap( + owner: BackgroundAgentJobOwner, +): Promise { + for (let index = 0; index < 105; index++) { + const filler = allocateBackgroundAgentJob({ + agentType: 'researcher', + agentName: `Filler ${index}`, + owner, + }) + await settleBackgroundAgentJob(filler, { output: `filler-${index}` }) + } +} + +/** Seed a settled shell `process` job directly on the shared registry. */ +function seedSettledProcessJob( + owner: BackgroundAgentJobOwner, + label: string, +): void { + const job = jobRegistry.create({ kind: 'process', label, owner }) + jobRegistry.start(job.jobId) + jobRegistry.emit(job.jobId, { type: 'lifecycle', state: 'completed' }) +} + +/** Seed a running shell `process` job directly on the shared registry. */ +function seedRunningProcessJob( + owner: BackgroundAgentJobOwner, + label: string, +): void { + const job = jobRegistry.create({ kind: 'process', label, owner }) + jobRegistry.start(job.jobId) +} + +/** Read the single json part of a tool handler output. */ +function readJsonToolValue(output: unknown): Record { + const part = Array.isArray(output) ? output[0] : output + return (part as { value: Record }).value +} + describe('background-agent-jobs registry', () => { beforeEach(() => { __clearBackgroundAgentJobsForTest() @@ -39,8 +135,8 @@ describe('background-agent-jobs registry', () => { agentName: 'Basher', }) const b = allocateBackgroundAgentJob({ - agentType: 'code-searcher', - agentName: 'Code Searcher', + agentType: 'file-picker', + agentName: 'File Picker', }) expect(a.jobId).not.toBe(b.jobId) }) @@ -69,6 +165,75 @@ describe('background-agent-jobs registry', () => { ).not.toThrow() }) + test('allocateBackgroundAgentJobBatch claims capacity atomically and launches nothing when over the limit', () => { + const owner = { + clientSessionId: 'session-batch-overflow', + rootRunId: 'root-batch-overflow', + parentRunId: 'parent-run', + parentAgentId: 'parent-agent', + userInputId: 'input-batch-overflow', + } + for (let index = 0; index < 7; index++) { + allocateBackgroundAgentJob({ + agentType: 'researcher', + agentName: `Researcher ${index}`, + owner, + }) + } + const runningBefore = listRunningBackgroundAgentJobs(owner).length + expect(runningBefore).toBe(7) + + // ONE capacity claim covers the whole batch: 7 + 2 > 8, so the batch is + // rejected before any record exists — no partial launch. + expect(() => + allocateBackgroundAgentJobBatch({ + agents: [ + { agentType: 'researcher', agentName: 'Researcher 7' }, + { agentType: 'researcher', agentName: 'Researcher 8' }, + ], + owner, + }), + ).toThrow('concurrency limit reached for this run (8)') + + expect(listRunningBackgroundAgentJobs(owner).length).toBe(runningBefore) + }) + + test('allocateBackgroundAgentJobBatch pre-allocates one distinct id per requested agent', () => { + const owner = { + clientSessionId: 'session-batch', + rootRunId: 'root-batch', + parentRunId: 'parent-run', + parentAgentId: 'parent-agent', + userInputId: 'input-batch', + } + const agents = [ + { agentType: 'basher', agentName: 'Basher' }, + { agentType: 'file-picker', agentName: 'File Picker' }, + { agentType: 'reviewer', agentName: 'Reviewer' }, + ] + + const jobs = allocateBackgroundAgentJobBatch({ agents, owner }) + + expect(jobs.length).toBe(3) + const ids = jobs.map((job) => job.jobId) + expect(new Set(ids).size).toBe(3) + for (const job of jobs) { + expect(job.jobId).toMatch(/^bg-agent-/) + expect(job.status).toBe('running') + // Every id resolves and can buffer chunks before any promise attaches. + expect(getBackgroundAgentJob(job.jobId)).toBe(job) + expect(job.chunks).toEqual([]) + expect(job.readOffset).toBe(0) + } + expect( + jobs.map((job) => ({ + agentType: job.agentType, + agentName: job.agentName, + })), + ).toEqual(agents) + expect(listRunningBackgroundAgentJobs(owner).length).toBe(3) + }) + test('getBackgroundAgentJob returns the job for a known id', () => { const job = allocateBackgroundAgentJob({ agentType: 'basher', @@ -308,6 +473,96 @@ describe('background-agent-jobs registry', () => { ).toThrow('concurrency limit reached for this run (8)') }) + test('assertBackgroundAgentJobOwned returns the core tri-state for a settled job', async () => { + const owner = { + clientSessionId: 'session-settled', + rootRunId: 'root-settled', + parentRunId: 'parent-run', + parentAgentId: 'parent-agent', + userInputId: 'input-settled', + } + const job = allocateBackgroundAgentJob({ + agentType: 'basher', + agentName: 'Basher', + owner, + }) + await settleBackgroundAgentJob(job, { output: 'settled' }) + expect(job.status).toBe('completed') + + expect(assertBackgroundAgentJobOwned(job.jobId, owner).ok).toBe(true) + expect(getBackgroundAgentJob(job.jobId)?.result).toEqual({ + output: 'settled', + }) + expect( + assertBackgroundAgentJobOwned(job.jobId, { + clientSessionId: owner.clientSessionId, + rootRunId: 'another-root', + }), + ).toMatchObject({ ok: false, reason: 'foreign' }) + + // Only an id the core itself no longer knows about is not_found. + expect( + assertBackgroundAgentJobOwned('bg-agent-job-never-allocated', owner), + ).toMatchObject({ ok: false, reason: 'not_found' }) + }) + + test('a count-cap-evicted settled job stays owned and still reports its core state and result', async () => { + const owner = { + clientSessionId: 'session-evicted', + rootRunId: 'root-evicted', + parentRunId: 'parent-run', + parentAgentId: 'parent-agent', + userInputId: 'input-evicted', + } + const job = allocateBackgroundAgentJob({ + agentType: 'basher', + agentName: 'Basher', + owner, + }) + await settleBackgroundAgentJob(job, { output: 'survives-eviction' }) + + await fillPastBackgroundAgentJobCountCap(owner) + + // The oldest settled adapter view was evicted by the count cap... + expect(getBackgroundAgentJob(job.jobId)).toBeUndefined() + // ...but the core record is the durable home of both the lifecycle state + // and the settled result, so a parent polling after eviction is still + // authorized and still sees the child's result. + expect(assertBackgroundAgentJobOwned(job.jobId, owner).ok).toBe(true) + const coreJob = getBackgroundAgentJobCore(job.jobId) + expect(coreJob?.state).toBe('completed') + expect(coreJob?.result).toEqual({ output: 'survives-eviction' }) + }) + + test('the count-cap sweep never evicts a non-terminal job, so it stays cancellable', async () => { + const owner = { + clientSessionId: 'session-cancellable', + rootRunId: 'root-cancellable', + parentRunId: 'parent-run', + parentAgentId: 'parent-agent', + userInputId: 'input-cancellable', + } + const running = allocateBackgroundAgentJob({ + agentType: 'basher', + agentName: 'Basher', + owner, + }) + // A coroutine that never settles keeps the core state non-terminal. + attachBackgroundAgentPromise(running, new Promise(() => {})) + + await fillPastBackgroundAgentJobCountCap(owner) + + // The view owns this job's AbortController, so the sweep must keep it... + expect(getBackgroundAgentJob(running.jobId)).toBe(running) + expect(running.status).toBe('running') + // ...and cancellation can still reach the coroutine. + expect(cancelBackgroundAgentJob(running.jobId)).toEqual({ + cancelled: true, + status: 'cancelled', + }) + expect(running.abortController.signal.aborted).toBe(true) + }) + test('cancelBackgroundAgentJob aborts a running coroutine and preserves cancelled status', async () => { const job = allocateBackgroundAgentJob({ agentType: 'basher', @@ -347,4 +602,626 @@ describe('background-agent-jobs registry', () => { expect(job.chunks.length).toBe(1) expect(job.chunks[0]!.payload).toBe('early') }) + + test('reconcileInterruptedBackgroundAgentIntents only reconciles running intents whose job is gone', () => { + const { mainAgentState } = getInitialSessionState(mockFileContext) + const live = allocateBackgroundAgentJob({ + agentType: 'basher', + agentName: 'Basher', + }) + mainAgentState.backgroundAgentJobs = [ + { + jobId: live.jobId, + agentType: 'basher', + status: 'running', + startedAt: live.startedAt, + }, + { + jobId: 'bg-agent-job-gone', + agentType: 'researcher', + status: 'running', + startedAt: 1, + }, + { + jobId: 'bg-agent-job-already-settled', + agentType: 'researcher', + status: 'completed', + startedAt: 2, + completedAt: 3, + }, + ] + + reconcileInterruptedBackgroundAgentIntents(mainAgentState) + // Idempotent: a second pass within the same turn changes nothing. + reconcileInterruptedBackgroundAgentIntents(mainAgentState) + + const intents = mainAgentState.backgroundAgentJobs! + // Intents are reconciled, never dropped. + expect(intents).toHaveLength(3) + expect(intents[0]!.status).toBe('running') + expect(intents[1]).toMatchObject({ + status: 'interrupted', + error: INTERRUPTED_INTENT_MESSAGE, + }) + expect(typeof intents[1]!.completedAt).toBe('number') + expect(intents[2]).toMatchObject({ status: 'completed', completedAt: 3 }) + }) + + test('shell process jobs do not consume the background-agent concurrency budget', () => { + const owner = { + clientSessionId: 'session-kinds', + rootRunId: 'root-kinds', + parentRunId: 'parent-run', + parentAgentId: 'parent-agent', + userInputId: 'input-kinds', + } + // Dev servers / watchers / tails share the process-wide registry. They are + // bounded separately, so they must not fill the background-AGENT quotas + // (8 per root, 32 total) and block every background agent spawn for a run. + for (let index = 0; index < 40; index++) { + seedRunningProcessJob(owner, `dev-server-${index}`) + } + + expect(() => + assertBackgroundAgentCapacity({ additional: 1, owner }), + ).not.toThrow() + expect(listRunningBackgroundAgentJobs(owner)).toEqual([]) + + const job = allocateBackgroundAgentJob({ + agentType: 'basher', + agentName: 'Basher', + owner, + }) + expect(listRunningBackgroundAgentJobs(owner).map((j) => j.jobId)).toEqual([ + job.jobId, + ]) + }) + + test('the view count cap is evaluated over adapter views, not the whole registry', async () => { + const owner = { + clientSessionId: 'session-view-cap', + rootRunId: 'root-view-cap', + parentRunId: 'parent-run', + parentAgentId: 'parent-agent', + userInputId: 'input-view-cap', + } + const job = allocateBackgroundAgentJob({ + agentType: 'basher', + agentName: 'Basher', + owner, + }) + await settleBackgroundAgentJob(job, { output: 'kept' }) + + // Far more process jobs than the 100-view bound: counting them would evict + // this settled agent view even though the adapter retains only one. + for (let index = 0; index < 120; index++) { + seedSettledProcessJob(owner, `tail-${index}`) + } + + expect(getBackgroundAgentJob(job.jobId)).toBe(job) + expect(getBackgroundAgentJob(job.jobId)?.result).toEqual({ output: 'kept' }) + }) + + test('cancelBackgroundAgentJob is an idempotent no-op for an already-settled job', async () => { + const owner = { + clientSessionId: 'session-idempotent-cancel', + rootRunId: 'root-idempotent-cancel', + parentRunId: 'parent-run', + parentAgentId: 'parent-agent', + userInputId: 'input-idempotent-cancel', + } + const job = allocateBackgroundAgentJob({ + agentType: 'basher', + agentName: 'Basher', + owner, + }) + await settleBackgroundAgentJob(job, { output: 'settled' }) + + // Not an error: the caller's poll must still be able to report the settled + // state/events/result instead of an error-only payload. + expect(cancelBackgroundAgentJob(job.jobId)).toEqual({ + cancelled: false, + status: 'completed', + }) + + await fillPastBackgroundAgentJobCountCap(owner) + expect(getBackgroundAgentJob(job.jobId)).toBeUndefined() + // Only SETTLED views are ever evicted, so a view-less known id is settled + // work — reporting not_found here would contradict that invariant. + expect(cancelBackgroundAgentJob(job.jobId)).toEqual({ + cancelled: false, + status: 'completed', + }) + expect(cancelBackgroundAgentJob('bg-agent-job-never-allocated')).toEqual({ + errorMessage: + 'No background agent job found with id "bg-agent-job-never-allocated".', + }) + }) + + test('abandonPreLaunchBackgroundAgentJob releases a stranded pre-launch job', () => { + const owner = { + clientSessionId: 'session-abandon', + rootRunId: 'root-abandon', + parentRunId: 'parent-run', + parentAgentId: 'parent-agent', + userInputId: 'input-abandon', + } + const job = allocateBackgroundAgentJob({ + agentType: 'basher', + agentName: 'Basher', + owner, + }) + const reason = 'spawn failed before launch' + abandonPreLaunchBackgroundAgentJob(job, reason) + + expect(getBackgroundAgentJob(job.jobId)).toBeUndefined() + expect(getBackgroundAgentJobCore(job.jobId)?.state).toBe('error') + expect(getBackgroundAgentJobCore(job.jobId)?.error).toBe(reason) + expect(listRunningBackgroundAgentJobs(owner)).toEqual([]) + expect(() => + assertBackgroundAgentCapacity({ additional: 1, owner }), + ).not.toThrow() + }) + + test('backgroundAgentJobWasCancelled distinguishes explicit cancel from running', () => { + const job = allocateBackgroundAgentJob({ + agentType: 'basher', + agentName: 'Basher', + }) + attachBackgroundAgentPromise(job, new Promise(() => {})) + expect(backgroundAgentJobWasCancelled(job)).toBe(false) + + expect(cancelBackgroundAgentJob(job.jobId)).toEqual({ + cancelled: true, + status: 'cancelled', + }) + expect(backgroundAgentJobWasCancelled(job)).toBe(true) + + const fresh = allocateBackgroundAgentJob({ + agentType: 'basher', + agentName: 'Basher', + }) + attachBackgroundAgentPromise(fresh, new Promise(() => {})) + expect(backgroundAgentJobWasCancelled(fresh)).toBe(false) + }) +}) + +describe('check_background_agent join semantics', () => { + const POLL_OWNER: BackgroundAgentJobOwner = { + clientSessionId: 'session-poll', + // handleCheckBackgroundAgent derives rootRunId from the agent state, whose + // initial shape has no ancestors/runId, so it resolves to the agent id. + rootRunId: 'main-agent', + parentRunId: 'main-agent', + parentAgentId: 'main-agent', + userInputId: 'input-poll', + } + + function startCheckBackgroundAgent( + input: Record, + options: { signal?: AbortSignal } = {}, + ): Promise> { + const { mainAgentState } = getInitialSessionState(mockFileContext) + return handleCheckBackgroundAgent({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolName: 'check_background_agent', + toolCallId: 'poll-background-agent', + input, + }, + agentState: mainAgentState, + clientSessionId: POLL_OWNER.clientSessionId, + signal: options.signal ?? new AbortController().signal, + } as unknown as Parameters[0]).then( + ({ output }) => readJsonToolValue(output), + ) + } + + beforeEach(() => { + __clearBackgroundAgentJobsForTest() + }) + + afterEach(() => { + mock.restore() + }) + + test('follow mode always resolves a finite deadline, capped at the documented maximum', () => { + // `wait_for` with no timeout used to await the registry with NO deadline, + // which could block the agent turn for the whole run. + expect( + resolveCheckBackgroundAgentWaitBounds({ waitFor: 'milestone' }), + ).toEqual({ + follow: true, + timeoutMs: DEFAULT_CHECK_BACKGROUND_AGENT_FOLLOW_TIMEOUT_MS, + }) + // Poll mode (documented `timeout_seconds: 0`) still returns immediately. + expect( + resolveCheckBackgroundAgentWaitBounds({ timeoutSeconds: 0 }), + ).toEqual({ + follow: false, + timeoutMs: DEFAULT_CHECK_BACKGROUND_AGENT_FOLLOW_TIMEOUT_MS, + }) + expect( + resolveCheckBackgroundAgentWaitBounds({ timeoutSeconds: 5 }), + ).toEqual({ follow: true, timeoutMs: 5_000 }) + // A non-finite or negative value is treated as omitted rather than as + // "no deadline". + for (const timeoutSeconds of [Number.NaN, Number.POSITIVE_INFINITY, -1]) { + expect( + resolveCheckBackgroundAgentWaitBounds({ + waitFor: 'milestone', + timeoutSeconds, + }), + ).toEqual({ + follow: true, + timeoutMs: DEFAULT_CHECK_BACKGROUND_AGENT_FOLLOW_TIMEOUT_MS, + }) + } + expect( + resolveCheckBackgroundAgentWaitBounds({ timeoutSeconds: 100_000 }), + ).toEqual({ + follow: true, + timeoutMs: MAX_CHECK_BACKGROUND_AGENT_FOLLOW_TIMEOUT_MS, + }) + }) + + test('a follow-mode wait without an explicit timeout still returns a bounded result', async () => { + const job = allocateBackgroundAgentJob({ + agentType: 'basher', + agentName: 'Basher', + owner: POLL_OWNER, + }) + let settleCoroutine!: (value: unknown) => void + attachBackgroundAgentPromise( + job, + new Promise((resolve) => { + settleCoroutine = resolve + }), + ) + + // No timeout_seconds: the handler must still hand the registry a deadline, + // so this join resolves as soon as the job settles rather than hanging. + const pending = startCheckBackgroundAgent({ + jobId: job.jobId, + wait_for: 'never-appears', + }) + await new Promise((resolve) => setTimeout(resolve, 5)) + settleCoroutine({ output: 'done' }) + + expect(await pending).toMatchObject({ + jobId: job.jobId, + state: 'completed', + matched: false, + }) + }) + + test('an aborted turn settles a follow-mode wait instead of holding the turn open', async () => { + const job = allocateBackgroundAgentJob({ + agentType: 'basher', + agentName: 'Basher', + owner: POLL_OWNER, + }) + attachBackgroundAgentPromise(job, new Promise(() => {})) + const controller = new AbortController() + + const pending = startCheckBackgroundAgent( + { jobId: job.jobId, wait_for: 'never-appears', timeout_seconds: 600 }, + { signal: controller.signal }, + ) + await new Promise((resolve) => setTimeout(resolve, 5)) + controller.abort() + + expect(await pending).toMatchObject({ + jobId: job.jobId, + state: 'running', + timedOut: true, + }) + }) + + test('a cursor past the latest sequence does not strand a follow-mode wait', async () => { + const job = allocateBackgroundAgentJob({ + agentType: 'basher', + agentName: 'Basher', + owner: POLL_OWNER, + }) + let settleCoroutine!: (value: unknown) => void + attachBackgroundAgentPromise( + job, + new Promise((resolve) => { + settleCoroutine = resolve + }), + ) + + // Unclamped, this cursor makes the terminal transition fail the + // `sequence > cursor` test, so the wait could only end by timing out. + const pending = startCheckBackgroundAgent({ + jobId: job.jobId, + wait_for: 'never-appears', + timeout_seconds: 5, + cursor: 10_000, + }) + await new Promise((resolve) => setTimeout(resolve, 5)) + settleCoroutine({ output: 'done' }) + + const value = await pending + // Events are lifecycle(queued)=1, lifecycle(running)=2, completed=3, so the + // clamped wait reports the terminal sequence instead of the bogus cursor. + expect(value).toMatchObject({ + jobId: job.jobId, + state: 'completed', + nextCursor: 3, + }) + expect(value.timedOut).toBeUndefined() + }) + + test('a repeated cancel poll on a settled job still returns its state, events, and result', async () => { + const job = allocateBackgroundAgentJob({ + agentType: 'basher', + agentName: 'Basher', + owner: POLL_OWNER, + }) + appendBackgroundAgentChunk(job.jobId, { + type: 'text', + payload: 'progress', + timestamp: 1, + }) + await settleBackgroundAgentJob(job, { output: 'settled' }) + + const value = await startCheckBackgroundAgent({ + jobId: job.jobId, + cancel: true, + }) + + expect(value).not.toHaveProperty('errorMessage') + expect(value).toMatchObject({ + jobId: job.jobId, + state: 'completed', + result: { output: 'settled' }, + }) + expect(Array.isArray(value.events)).toBe(true) + // A no-op cancel must not relabel an already-completed job as cancelled. + expect(value.cancelled).toBeUndefined() + }) + + test('a cancel poll on a count-cap-evicted settled job reports the settled job instead of not_found', async () => { + const job = allocateBackgroundAgentJob({ + agentType: 'basher', + agentName: 'Basher', + owner: POLL_OWNER, + }) + await settleBackgroundAgentJob(job, { output: 'survives-eviction' }) + await fillPastBackgroundAgentJobCountCap(POLL_OWNER) + expect(getBackgroundAgentJob(job.jobId)).toBeUndefined() + + const value = await startCheckBackgroundAgent({ + jobId: job.jobId, + cancel: true, + }) + + expect(value).not.toHaveProperty('errorMessage') + expect(value).toMatchObject({ + jobId: job.jobId, + state: 'completed', + result: { output: 'survives-eviction' }, + }) + }) + + test('reports not_found only for an id the core never knew', async () => { + expect( + await startCheckBackgroundAgent({ + jobId: 'bg-agent-job-never-allocated', + }), + ).toEqual({ + jobId: 'bg-agent-job-never-allocated', + errorMessage: + 'No background agent job found with id "bg-agent-job-never-allocated".', + }) + }) + + test('cursorless polls return only chunks since the last poll', async () => { + const job = allocateBackgroundAgentJob({ + agentType: 'basher', + agentName: 'Basher', + owner: POLL_OWNER, + }) + attachBackgroundAgentPromise(job, new Promise(() => {})) + appendBackgroundAgentChunk(job.jobId, { + type: 'text', + payload: 'chunk-A', + timestamp: 1, + }) + + const first = await startCheckBackgroundAgent({ jobId: job.jobId }) + expect(JSON.stringify(first.events)).toContain('chunk-A') + expect(first.truncated).toBe(false) + const firstCursor = first.nextCursor as number + + appendBackgroundAgentChunk(job.jobId, { + type: 'text', + payload: 'chunk-B', + timestamp: 2, + }) + + const second = await startCheckBackgroundAgent({ jobId: job.jobId }) + expect(JSON.stringify(second.events)).toContain('chunk-B') + expect(JSON.stringify(second.events)).not.toContain('chunk-A') + expect(second.nextCursor as number).toBeGreaterThan(firstCursor) + expect(second.truncated).toBe(false) + }) +}) + +describe('spawn_agents background intent reconciliation', () => { + let baseParams: ParamsExcluding< + typeof handleSpawnAgents, + 'agentState' | 'agentTemplate' | 'localAgentTemplates' | 'toolCall' + > + + const createMockAgent = ( + id: string, + spawnableAgents: string[] = [], + ): AgentTemplate => ({ + id, + displayName: `Mock ${id}`, + outputMode: 'last_message' as const, + inputSchema: { + prompt: { + safeParse: () => ({ success: true }), + } as unknown as AgentTemplate['inputSchema']['prompt'], + }, + spawnerPrompt: '', + model: '', + includeMessageHistory: true, + inheritParentSystemPrompt: false, + mcpServers: {}, + toolNames: [], + spawnableAgents, + systemPrompt: '', + instructionsPrompt: '', + stepPrompt: '', + }) + + beforeEach(() => { + __clearBackgroundAgentJobsForTest() + baseParams = { + ...TEST_AGENT_RUNTIME_IMPL, + ancestorRunIds: [], + clientSessionId: 'test-session', + fileContext: mockFileContext, + fingerprintId: 'test-fingerprint', + previousToolCallFinished: Promise.resolve(), + repoId: undefined, + repoUrl: undefined, + sendSubagentChunk: mock(() => {}), + signal: new AbortController().signal, + system: 'Test system prompt', + userId: TEST_USER_ID, + userInputId: 'test-input', + writeToClient: () => {}, + } + spyOn(runAgentStep, 'loopAgentSteps').mockImplementation( + async (options) => ({ + agentState: { + ...options.agentState, + messageHistory: [assistantMessage('Mock agent response')], + }, + output: { + type: 'lastMessage', + value: [assistantMessage('Mock agent response')], + }, + }), + ) + }) + + afterEach(() => { + mock.restore() + }) + + test('a background spawn succeeds when the parent still lists running intents whose jobs are gone', async () => { + const parentAgent = createMockAgent('parent', ['thinker']) + const childAgent = createMockAgent('thinker') + const { mainAgentState } = getInitialSessionState(mockFileContext) + // The whole per-root background budget is consumed by intents whose jobs no + // longer exist (settled + count-cap evicted, or from a previous session). + mainAgentState.backgroundAgentJobs = Array.from( + { length: 8 }, + (_, index) => ({ + jobId: `bg-agent-job-gone-${index}`, + agentType: 'researcher', + status: 'running' as const, + startedAt: index, + }), + ) + + const { output } = await handleSpawnAgents({ + ...baseParams, + agentState: mainAgentState, + agentTemplate: parentAgent, + localAgentTemplates: { thinker: childAgent }, + toolCall: { + toolName: 'spawn_agents', + toolCallId: 'spawn-background-after-eviction', + input: { + agents: [ + { agent_type: 'thinker', prompt: 'background', background: true }, + ], + }, + }, + }) + + const intents = mainAgentState.backgroundAgentJobs ?? [] + // The stale intents were reconciled (not dropped), freeing the budget, and + // the newly launched job was appended. + expect(intents).toHaveLength(9) + expect(intents.slice(0, 8).map((intent) => intent.status)).toEqual( + Array.from({ length: 8 }, () => 'interrupted'), + ) + expect(intents[0]!.error).toBe(INTERRUPTED_INTENT_MESSAGE) + expect(typeof intents[0]!.completedAt).toBe('number') + + const newJobId = intents[8]!.jobId + expect(newJobId).toMatch(/^bg-agent-/) + expect(output[0]?.type).toBe('json') + expect(JSON.stringify(output)).toContain('"background":true') + expect(JSON.stringify(output)).toContain(newJobId) + }) + + test('a rejected background batch terminates the spawn_started events it already emitted', async () => { + const parentAgent = createMockAgent('parent', ['thinker']) + const childAgent = createMockAgent('thinker') + const { mainAgentState } = getInitialSessionState(mockFileContext) + // Saturate the per-root background-agent quota with REAL running jobs so + // the batch allocation throws AFTER spawn_started was already emitted. + const owner = { + clientSessionId: 'test-session', + rootRunId: mainAgentState.agentId, + parentRunId: mainAgentState.agentId, + parentAgentId: mainAgentState.agentId, + userInputId: 'test-input', + } + for (let index = 0; index < 8; index++) { + allocateBackgroundAgentJob({ + agentType: 'researcher', + agentName: `Researcher ${index}`, + owner, + }) + } + + await expect( + handleSpawnAgents({ + ...baseParams, + agentState: mainAgentState, + agentTemplate: parentAgent, + localAgentTemplates: { thinker: childAgent }, + toolCall: { + toolName: 'spawn_agents', + toolCallId: 'spawn-background-over-quota', + input: { + agents: [ + { agent_type: 'thinker', prompt: 'background', background: true }, + ], + }, + }, + }), + ).rejects.toThrow('concurrency limit reached for this run (8)') + + const events = mainAgentState.orchestrationLedger?.events ?? [] + const startedSpawnIds = events.flatMap((event) => + event.type === 'spawn_started' ? [event.spawnId] : [], + ) + const interruptedSpawnIds = events.flatMap((event) => + event.type === 'interrupted' && event.subjectType === 'spawn' + ? [event.subjectId] + : [], + ) + + expect(startedSpawnIds).toHaveLength(1) + // The rollback must settle the emitted spawn, or the ledger keeps reporting + // a spawn that never launched as in-flight for the rest of the turn. + expect(interruptedSpawnIds).toEqual(startedSpawnIds) + expect( + mainAgentState.workspacePathLeases?.filter( + (lease) => lease.status === 'active', + ) ?? [], + ).toHaveLength(0) + }) }) diff --git a/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts b/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts index 9b72013cb2..6aea429656 100644 --- a/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts +++ b/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts @@ -605,7 +605,7 @@ describe('Schema handling error recovery', () => { additionalToolDefinitions: async () => ({}), agentTools: {}, skills: {}, - spawnableAgentTypes: ['file-picker', 'code-searcher'], + spawnableAgentTypes: ['file-picker', 'general-agent'], }) const jsonSchema = ( @@ -622,7 +622,7 @@ describe('Schema handling error recovery', () => { expect(parseAgentType('file-picker').success).toBe(true) expect(parseAgentType('file_picker').success).toBe(true) - expect(parseAgentType('code-searcher').success).toBe(true) + expect(parseAgentType('general-agent').success).toBe(true) expect(parseAgentType('file-explorer').success).toBe(false) expect(parseAgentType('read_files').success).toBe(false) }) diff --git a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts index 2a80910a07..bbb43a9a64 100644 --- a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts +++ b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts @@ -2932,7 +2932,7 @@ describe('runAgentStep - set_output tool', () => { }) // Force a second tool-call batch after suggest_followups has executed. yield createToolCallChunk('spawn_agents', { - agents: [{ agent_type: 'code-searcher', prompt: 'Search more' }], + agents: [{ agent_type: 'file-picker', prompt: 'Search more' }], }) return promptSuccess('mock-message-id') } @@ -2948,7 +2948,7 @@ describe('runAgentStep - set_output tool', () => { ...testAgent, id: 'test-followup-agent', toolNames: ['spawn_agents', 'suggest_followups', 'end_turn'], - spawnableAgents: ['code-searcher'], + spawnableAgents: ['file-picker'], } await runAgentStep({ diff --git a/packages/agent-runtime/src/__tests__/spawn-agent-inline-nesting.test.ts b/packages/agent-runtime/src/__tests__/spawn-agent-inline-nesting.test.ts index a9c3d5ca1d..c0e2db8344 100644 --- a/packages/agent-runtime/src/__tests__/spawn-agent-inline-nesting.test.ts +++ b/packages/agent-runtime/src/__tests__/spawn-agent-inline-nesting.test.ts @@ -614,13 +614,14 @@ describe('spawn_agent_inline onResponseChunk parentAgentId nesting', () => { }) it('bounds ordinary child output before returning it to the parent', () => { + const longAnswer = 'x'.repeat(120_000) const normalized = normalizeSpawnedAgentOutput( { type: 'lastMessage', value: [ { role: 'assistant', - content: [{ type: 'text', text: 'x'.repeat(120_000) }], + content: [{ type: 'text', text: longAnswer }], }, ], }, @@ -628,10 +629,32 @@ describe('spawn_agent_inline onResponseChunk parentAgentId nesting', () => { ) const serialized = JSON.stringify(normalized) - expect(serialized.length).toBeLessThan(10_000) + // `text` is an answer-bearing channel, so it keeps the high-fidelity cap + // (32k) rather than the 4k default — still bounded far below the input. + expect(serialized.length).toBeLessThan(40_000) + expect(serialized.length).toBeLessThan(longAnswer.length / 2) expect(serialized).toContain('truncated') }) + it('keeps the default cap for incidental fields while answer fields survive', () => { + const normalized = normalizeSpawnedAgentOutput( + { + type: 'structuredOutput', + value: { + note: 'y'.repeat(20_000), + summary: 'z'.repeat(20_000), + }, + }, + 'general-agent', + ) as { value: { note: string; summary: string } } + + // `note` is incidental metadata: still clipped at the 4k default. + expect(normalized.value.note.length).toBeLessThan(5_000) + expect(normalized.value.note).toContain('[truncated]') + // A same-length answer-bearing field survives intact. + expect(normalized.value.summary).toHaveLength(20_000) + }) + it('preserves compact diagnostics from deeply nested child output', () => { const normalized = normalizeSpawnedAgentOutput( { @@ -718,6 +741,77 @@ describe('spawn_agent_inline onResponseChunk parentAgentId nesting', () => { expect(receipt.errors[0]?.retryable).toBe(true) }) + // The generator harvest never sets AgentState.consecutiveTextOnlyWithoutCompletion + // (and the step-cap early return never touches it), so completion credit must + // come from the harvest flag alone. Otherwise the same harvest suppresses the + // retryable 'no task_completed' error on one exit path and not the other. + it('credits a harvested-fallback general agent without the text-only counter', () => { + const harvestedOutput = { + summary: 'Harvested final answer for the parent.', + harvestedFromFallback: true, + } + const receipt = buildRuntimeAgentReceipt({ + agentType: 'general-agent', + agentId: 'general-harvested-fallback', + output: { type: 'structuredOutput', value: harvestedOutput }, + agentState: { + output: harvestedOutput, + messageHistory: [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'Harvested final answer for the parent.' }, + ], + }, + ], + } as any, + }) + + expect(receipt.status).toBe('completed') + expect(receipt.errors).toEqual([]) + expect( + receipt.errors.some((error) => error.message.includes('task_completed')), + ).toBe(false) + }) + + // The harvest always emits set_output so the parent never sees value: null, + // but an answerless / step-capped run only recovered a placeholder summary + // and marks itself with noHarvestedAnswer. Crediting that as explicit + // completion would report a step-capped child as completed with zero errors, + // leaving the parent no signal to re-spawn. + it('does not credit an answerless harvest as explicit completion', () => { + const harvestedOutput = { + summary: + 'No answer text was produced before this agent hit its step cap, so there is no harvested final answer to report.', + harvestedFromFallback: true, + noHarvestedAnswer: true, + } + const receipt = buildRuntimeAgentReceipt({ + agentType: 'general-agent', + agentId: 'general-answerless-harvest', + output: { type: 'structuredOutput', value: harvestedOutput }, + agentState: { + output: harvestedOutput, + messageHistory: [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'Maximum number of steps reached.' }, + ], + tags: ['STEP_CAP_REACHED'], + }, + ], + } as any, + }) + + expect(receipt.status).toBe('partial') + expect( + receipt.errors.some( + (error) => error.retryable && error.message.includes('task_completed'), + ), + ).toBe(true) + }) + it('RF-2/RF-7/RF-11/RF-16 completes blocked repair-editor output when mutations are attested', () => { const receipt = buildRuntimeAgentReceipt({ agentType: 'repair-editor', @@ -1042,4 +1136,122 @@ describe('spawn_agent_inline onResponseChunk parentAgentId nesting', () => { expect(receipt.artifacts).toEqual([artifactPath]) expect(receipt.errors).toEqual([]) }) + + it('accepts an already-persisted collision result as the audit gate for a general audit agent', () => { + // write_audit_findings creates the artifact exclusively, so an + // already-exists rejection means this shard's findings are already durably + // at that path. The rejection carries no structuralReceipt, so without the + // snapshot-bound already-persisted marker the shard would resolve partial + // with a retryable audit-receipt error despite its persisted findings. + const artifactPath = '.agents/sessions/readiness/findings/services.md' + const receipt = buildRuntimeAgentReceipt({ + agentType: 'general-agent', + agentId: 'general-audit-collision', + spawnParams: { + sessionSlug: 'readiness', + shardId: 'services', + snapshotId: 'snapshot-1', + }, + output: { + type: 'lastMessage', + value: [ + { + role: 'assistant', + content: [ + { type: 'tool-call', toolName: 'task_completed', input: {} }, + ], + }, + ], + }, + agentState: { + messageHistory: [ + { + role: 'tool', + toolName: 'write_audit_findings', + content: [ + { + type: 'json', + value: { + artifactPath, + errorMessage: `Failed to create file: the file already exists. Shard id "services": this shard's findings are already persisted at ${artifactPath}; treat this as already written and do not write a duplicate.`, + alreadyPersisted: { + schema_version: 1, + shardId: 'services', + artifactPath, + snapshot_id: 'snapshot-1', + }, + }, + }, + ], + }, + ], + } as any, + }) + + expect(receipt.status).toBe('completed') + expect(receipt.errors).toEqual([]) + expect( + receipt.errors.some((error) => + error.message.includes('write_audit_findings'), + ), + ).toBe(false) + }) + + it('still fails the audit gate when the collision marker names another snapshot', () => { + // The snapshot binding must keep the gate unforgeable by any colliding + // write, so a marker for a different snapshot stays partial + retryable. + const artifactPath = '.agents/sessions/readiness/findings/services.md' + const receipt = buildRuntimeAgentReceipt({ + agentType: 'general-agent', + agentId: 'general-audit-collision-mismatch', + spawnParams: { + sessionSlug: 'readiness', + shardId: 'services', + snapshotId: 'snapshot-2', + }, + output: { + type: 'lastMessage', + value: [ + { + role: 'assistant', + content: [ + { type: 'tool-call', toolName: 'task_completed', input: {} }, + ], + }, + ], + }, + agentState: { + messageHistory: [ + { + role: 'tool', + toolName: 'write_audit_findings', + content: [ + { + type: 'json', + value: { + artifactPath, + errorMessage: 'the file already exists', + alreadyPersisted: { + schema_version: 1, + shardId: 'services', + artifactPath, + snapshot_id: 'snapshot-1', + }, + }, + }, + ], + }, + ], + } as any, + }) + + expect(receipt.status).toBe('partial') + expect( + receipt.errors.some( + (error) => + error.retryable && + error.message.includes('write_audit_findings structuralReceipt'), + ), + ).toBe(true) + }) }) diff --git a/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts b/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts index fd72d8db81..97325978c3 100644 --- a/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts +++ b/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts @@ -112,13 +112,6 @@ describe('Spawn Agents Permissions', () => { ).toBe('openbuff/file-picker@1.0.0') }) - it('corrects the common code-searcher spawn typo', () => { - expect(normalizeSpawnAgentType('code-searccher')).toBe('code-searcher') - expect(getMatchingSpawn(['code-searcher'], 'code-searccher')).toBe( - 'code-searcher', - ) - }) - it('normalizes underscored spawn agent types to hyphenated ids', () => { expect(normalizeSpawnAgentType('file_picker')).toBe('file-picker') }) @@ -157,25 +150,19 @@ describe('Spawn Agents Permissions', () => { expect(JSON.stringify(output)).toContain('Mock agent response') }) - it('derives a discovery question for params-only code-searcher spawns', async () => { - const parentAgent = createMockAgent('parent', ['code-searcher']) - const childAgent = createMockAgent('code-searcher') + it('derives a discovery question for params-only file-picker spawns', async () => { + const parentAgent = createMockAgent('parent', ['file-picker']) + const childAgent = createMockAgent('file-picker') const sessionState = getInitialSessionState(mockFileContext) const toolCall: CodebuffToolCall<'spawn_agents'> = { toolName: 'spawn_agents', - toolCallId: 'spawn-code-searcher-without-prompt', + toolCallId: 'spawn-file-picker-without-prompt', input: { agents: [ { - agent_type: 'code-searcher', + agent_type: 'file-picker', params: { - searchQueries: [ - { - pattern: 'worker|queue|analysis', - cwd: 'server/src/__tests__', - flags: '-g *.test.ts', - }, - ], + directories: ['server/src/__tests__'], }, }, ], @@ -186,7 +173,7 @@ describe('Spawn Agents Permissions', () => { ...handleSpawnAgentsBaseParams, agentState: sessionState.mainAgentState, agentTemplate: parentAgent, - localAgentTemplates: { 'code-searcher': childAgent }, + localAgentTemplates: { 'file-picker': childAgent }, toolCall, }) @@ -197,22 +184,22 @@ describe('Spawn Agents Permissions', () => { expect( sessionState.mainAgentState.discoveryCoverage?.shards[0], ).toMatchObject({ - agentType: 'code-searcher', + agentType: 'file-picker', status: 'completed', }) expect( sessionState.mainAgentState.discoveryCoverage?.shards[0].question, - ).toContain('worker|queue|analysis') + ).toContain('server/src/__tests__') }) it('does not retain partial discovery claims when a batch has duplicates', async () => { - const parentAgent = createMockAgent('parent', ['code-searcher']) - const childAgent = createMockAgent('code-searcher') + const parentAgent = createMockAgent('parent', ['file-picker']) + const childAgent = createMockAgent('file-picker') const sessionState = getInitialSessionState(mockFileContext) const duplicate = { - agent_type: 'code-searcher' as const, + agent_type: 'file-picker' as const, params: { - searchQueries: [{ pattern: 'worker', cwd: 'server/src/__tests__' }], + directories: ['server/src/__tests__'], }, } @@ -221,10 +208,10 @@ describe('Spawn Agents Permissions', () => { ...handleSpawnAgentsBaseParams, agentState: sessionState.mainAgentState, agentTemplate: parentAgent, - localAgentTemplates: { 'code-searcher': childAgent }, + localAgentTemplates: { 'file-picker': childAgent }, toolCall: { toolName: 'spawn_agents', - toolCallId: 'spawn-duplicate-code-searchers', + toolCallId: 'spawn-duplicate-file-pickers', input: { agents: [duplicate, duplicate] }, }, }), @@ -434,7 +421,7 @@ describe('Spawn Agents Permissions', () => { it('keeps child static spawnableAgents after handoff', () => { const parentAgent = createMockAgent('orchestrator', ['repair-editor']) - const childAgent = createMockAgent('repair-editor', ['code-searcher']) + const childAgent = createMockAgent('repair-editor', ['file-picker']) childAgent.toolNames = ['edit_transaction'] const derived = deriveSpawnTemplateCapabilities({ @@ -444,7 +431,7 @@ describe('Spawn Agents Permissions', () => { projectRoot: mockFileContext.projectRoot, }) - expect(derived.spawnableAgents).toEqual(['code-searcher']) + expect(derived.spawnableAgents).toEqual(['file-picker']) }) it('keeps programmatic tools after handoff even when not in allowedTools', () => { diff --git a/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts b/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts index 6007578510..3f1c62d112 100644 --- a/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts +++ b/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts @@ -841,8 +841,8 @@ describe('tool validation error handling', () => { input: { agents: [ { - agent_type: 'code-searcher', - params: { searchQueries: [{ pattern: 'x' }] }, + agent_type: 'file-picker', + params: { directories: ['src'] }, }, ], prompt: 'find x', @@ -854,7 +854,7 @@ describe('tool validation error handling', () => { if (!('error' in result)) { expect(result.input.agents[0].prompt).toBe('find x') expect(result.input.agents[0].params).toEqual({ - searchQueries: [{ pattern: 'x' }], + directories: ['src'], }) } }) @@ -885,7 +885,7 @@ describe('tool validation error handling', () => { toolName: 'spawn_agents', toolCallId: 'spawn-agents-single-agent-no-overwrite-tool-call-id', input: { - agents: [{ agent_type: 'code-searcher', prompt: 'inner' }], + agents: [{ agent_type: 'file-picker', prompt: 'inner' }], prompt: 'outer', }, }, @@ -925,7 +925,7 @@ describe('tool validation error handling', () => { toolCallId: 'spawn-agents-stringified-misbraced-prompt-tool-call-id', input: { agents: - '[{"agent_type": "code-searcher", "params": {"searchQueries": [{"pattern": "serialized handleSteps", "flags": "-g *.ts"}]}}, "prompt": "Find the test in the agents test suite."}]', + '[{"agent_type": "file-picker", "params": {"directories": [{"pattern": "serialized handleSteps", "flags": "-g *.ts"}]}}, "prompt": "Find the test in the agents test suite."}]', }, }, }) @@ -2428,33 +2428,6 @@ describe('tool validation error handling', () => { expect(message).toContain('Preserve params field names exactly.') }) - it('gives code-searcher a searchQueries recovery hint on empty params', async () => { - const { validateAgentInput } = - await import('../tools/handlers/tool/spawn-agent-utils') - const codeSearcher = { - ...testAgentTemplate, - id: 'code-searcher', - inputSchema: { - params: z.object({ - searchQueries: z.array(z.object({ pattern: z.string() })), - }), - }, - } - - let message = '' - try { - validateAgentInput(codeSearcher, 'code-searcher', undefined, {}) - } catch (error) { - message = error instanceof Error ? error.message : String(error) - } - - expect(message).toContain('Missing required: searchQueries') - expect(message).toContain('spawn code-searcher with') - expect(message).toContain('"searchQueries"') - expect(message).toContain('required array of objects') - expect(message).toContain('Preserve params field names exactly.') - }) - it('publishes a structured failure result when Basher is missing command', async () => { const parent: AgentTemplate = { ...testAgentTemplate, @@ -3386,16 +3359,15 @@ describe('buildUnavailableToolMessage', () => { toolName, agentId: 'base2', availableTools: ['read_files', 'code_search'], - input: { pattern: 'alpha' }, }) expect(message).toContain('Use the granted `code_search` tool directly') - expect(message).toContain('params.searchQueries') - expect(message).not.toContain('"pattern": "alpha"') + expect(message).toContain('one `code_search` call per pattern') + expect(message).not.toContain('code-searcher') } }) - it('gives concrete code-searcher recovery when code_search is unavailable', () => { + it('falls back to generic not-granted guidance when code_search is unavailable', () => { for (const toolName of ['code_search', 'find_files_matching_content']) { const message = buildUnavailableToolMessage({ toolName, @@ -3403,25 +3375,14 @@ describe('buildUnavailableToolMessage', () => { availableTools: ['read_files'], }) - expect(message).toContain('code-searcher') - expect(message).toContain('searchQueries') - expect(message).toContain('"pattern": ""') + expect(message).toContain( + 'is a registered tool but is not granted to this agent', + ) + expect(message).not.toContain('code-searcher') + expect(message).not.toContain('searchQueries') } }) - it('inlines an explicit input pattern into the code-searcher spawn recipe', () => { - const message = buildUnavailableToolMessage({ - toolName: 'code_search', - agentId: 'base2', - availableTools: ['read_files'], - input: { pattern: 'normalizeSpawnAgentList' }, - }) - - expect(message).toContain('code-searcher') - expect(message).toContain('"pattern": "normalizeSpawnAgentList"') - expect(message).not.toContain('"pattern": ""') - }) - it('suggests the closest granted tool for a likely typo', () => { const message = buildUnavailableToolMessage({ toolName: 'read_file', diff --git a/packages/agent-runtime/src/orchestration/__tests__/discovery-coordinator.test.ts b/packages/agent-runtime/src/orchestration/__tests__/discovery-coordinator.test.ts index a87029b98d..859b137bbb 100644 --- a/packages/agent-runtime/src/orchestration/__tests__/discovery-coordinator.test.ts +++ b/packages/agent-runtime/src/orchestration/__tests__/discovery-coordinator.test.ts @@ -5,12 +5,13 @@ import { claimDiscoveryShard, completeDiscoveryShard, planDiscoveryBatch, + reconcileInterruptedDiscoveryShards, } from '../discovery-coordinator' describe('discovery coordinator', () => { test('derives a stable non-empty question for params-only discovery agents', () => { const first = buildDiscoveryQuestion({ - agentType: 'code-searcher', + agentType: 'file-picker', spawnParams: { searchQueries: [ { cwd: 'server/src', flags: '-g *.test.ts', pattern: 'worker' }, @@ -18,7 +19,7 @@ describe('discovery coordinator', () => { }, }) const reordered = buildDiscoveryQuestion({ - agentType: 'code-searcher', + agentType: 'file-picker', spawnParams: { searchQueries: [ { pattern: 'worker', flags: '-g *.test.ts', cwd: 'server/src' }, @@ -33,12 +34,12 @@ describe('discovery coordinator', () => { test('never records an empty shard question', () => { const claimed = claimDiscoveryShard({ - agentType: 'code-searcher', + agentType: 'file-picker', question: ' ', workspaceRevision: 1, }) - expect(claimed.state.shards[0].question).toBe('code-searcher discovery') + expect(claimed.state.shards[0].question).toBe('file-picker discovery') }) test('deduplicates candidates and merges evidence reasons', () => { @@ -148,7 +149,7 @@ describe('discovery coordinator', () => { test('allows a failed shard to be retried and records completion', () => { const claimed = claimDiscoveryShard({ - agentType: 'code-searcher', + agentType: 'file-picker', question: 'mutation broker', workspaceRevision: 4, }) @@ -159,7 +160,7 @@ describe('discovery coordinator', () => { }) const retried = claimDiscoveryShard({ existing: failed, - agentType: 'code-searcher', + agentType: 'file-picker', question: 'broker mutation', workspaceRevision: 4, }) @@ -169,4 +170,59 @@ describe('discovery coordinator', () => { expect(retried.shardKey).toBe(claimed.shardKey) expect(retried.state.shards).toHaveLength(2) }) + + test('reconciles a shard left active by an interrupted spawn so it can be reclaimed', () => { + const claimed = claimDiscoveryShard({ + agentType: 'file-picker', + question: 'interrupted question', + workspaceRevision: 5, + }) + // Without reconciliation the still-active claim makes every later claim for + // this question throw, which fails the whole spawn batch. + expect(() => + claimDiscoveryShard({ + existing: claimed.state, + agentType: 'file-picker', + question: 'question interrupted', + workspaceRevision: 5, + }), + ).toThrow('Duplicate discovery shard') + + const reconciled = reconcileInterruptedDiscoveryShards(claimed.state)! + + expect(reconciled.shards[0]).toMatchObject({ status: 'interrupted' }) + expect(reconciled.shards[0].completedAt).toBeNumber() + expect(reconciled.revision).toBe(claimed.state.revision + 1) + expect(() => + claimDiscoveryShard({ + existing: reconciled, + agentType: 'file-picker', + question: 'question interrupted', + workspaceRevision: 5, + }), + ).not.toThrow() + }) + + test('leaves settled shards and missing coverage untouched', () => { + const claimed = claimDiscoveryShard({ + agentType: 'file-picker', + question: 'settled question', + workspaceRevision: 6, + }) + const completed = completeDiscoveryShard({ + existing: claimed.state, + shardKey: claimed.shardKey, + status: 'completed', + })! + + // Idempotent: nothing is active, so the same state comes back without + // revision churn (and an absent coverage state stays absent). + expect(reconcileInterruptedDiscoveryShards(completed)).toBe(completed) + expect(reconcileInterruptedDiscoveryShards(undefined)).toBeUndefined() + + const reconciledOnce = reconcileInterruptedDiscoveryShards(claimed.state)! + expect(reconcileInterruptedDiscoveryShards(reconciledOnce)).toBe( + reconciledOnce, + ) + }) }) diff --git a/packages/agent-runtime/src/orchestration/discovery-coordinator.ts b/packages/agent-runtime/src/orchestration/discovery-coordinator.ts index aed35f33c5..3e438abe5b 100644 --- a/packages/agent-runtime/src/orchestration/discovery-coordinator.ts +++ b/packages/agent-runtime/src/orchestration/discovery-coordinator.ts @@ -228,3 +228,36 @@ export function completeDiscoveryShard(params: { ), }) } + +/** + * Settle discovery shards left `active` by a spawn that never recorded a + * terminal receipt (an interrupted or unsettled turn). Shard claims are durable + * parent state, so without this an `active` shard would make + * {@link claimDiscoveryShard} throw for that question forever — and that throw + * fails the whole spawn batch. An `interrupted` shard is reclaimable, exactly + * like a `failed` one, so the next turn can legitimately re-ask the question. + * + * Call ONLY at run entry, where no spawn of the current run is in flight yet: + * an `active` shard observed there necessarily belongs to a previous, + * interrupted turn. Idempotent — a reconciled shard is no longer `active`, and + * a state with nothing to reconcile is returned unchanged (same reference, no + * revision churn). + */ +export function reconcileInterruptedDiscoveryShards( + existing?: DiscoveryCoverageV1, +): DiscoveryCoverageV1 | undefined { + const hasActiveShard = existing?.shards.some( + (shard) => shard.status === 'active', + ) + if (!existing || !hasActiveShard) return existing + const completedAt = Date.now() + return discoveryCoverageV1Schema.parse({ + ...existing, + revision: existing.revision + 1, + shards: existing.shards.map((shard) => + shard.status === 'active' + ? { ...shard, status: 'interrupted', completedAt } + : shard, + ), + }) +} diff --git a/packages/agent-runtime/src/run-agent-step.ts b/packages/agent-runtime/src/run-agent-step.ts index cd2d52cfa4..405a987e70 100644 --- a/packages/agent-runtime/src/run-agent-step.ts +++ b/packages/agent-runtime/src/run-agent-step.ts @@ -27,9 +27,10 @@ import { reconcileInterruptedLedgerSpawns, } from './util/orchestration-ledger' import { reconcileInterruptedPathLeases } from './util/workspace-path-leases' +import { reconcileInterruptedDiscoveryShards } from './orchestration/discovery-coordinator' import { additionalSystemPrompts } from './system-prompt/prompts' import { getAgentTemplate } from './templates/agent-registry' -import { getBackgroundAgentJob } from './util/background-agent-jobs' +import { reconcileInterruptedBackgroundAgentIntents } from './util/background-agent-jobs' import { buildAgentToolSet, getModelVisibleSpawnableAgents, @@ -1340,6 +1341,13 @@ export async function loopAgentSteps( initialAgentState.contextWindowTokens = resolvedModelContextWindow reconcileInterruptedLedgerSpawns(initialAgentState) reconcileInterruptedPathLeases(initialAgentState) + // Discovery shard claims are durable parent state too: a shard left 'active' + // by an interrupted spawn would otherwise make claimDiscoveryShard throw for + // that question forever, failing the whole spawn batch. Nothing of THIS run + // is in flight yet here, so an 'active' shard belongs to a previous turn. + initialAgentState.discoveryCoverage = reconcileInterruptedDiscoveryShards( + initialAgentState.discoveryCoverage, + ) if ( !initialAgentState.orchestrationLedger?.events.some( (event) => @@ -1362,14 +1370,7 @@ export async function loopAgentSteps( }, }) } - for (const job of initialAgentState.backgroundAgentJobs ?? []) { - if (job.status === 'running' && !getBackgroundAgentJob(job.jobId)) { - job.status = 'interrupted' - job.completedAt = Date.now() - job.error = - 'Background agent host process/session ended before a terminal receipt was recorded.' - } - } + reconcileInterruptedBackgroundAgentIntents(initialAgentState) if (signal.aborted) { return { diff --git a/packages/agent-runtime/src/templates/__tests__/strings.test.ts b/packages/agent-runtime/src/templates/__tests__/strings.test.ts index e01b35fe6b..35c7d00b15 100644 --- a/packages/agent-runtime/src/templates/__tests__/strings.test.ts +++ b/packages/agent-runtime/src/templates/__tests__/strings.test.ts @@ -25,7 +25,6 @@ import gitCommitter from '../../../../../agents/git-committer/git-committer' import librarian from '../../../../../agents/librarian/librarian' import dependencyManager from '../../../../../agents/dependency-manager/dependency-manager' import securityReviewer from '../../../../../agents/security-reviewer/security-reviewer' -import codeSearcher from '../../../../../agents/file-explorer/code-searcher' import globMatcher from '../../../../../agents/file-explorer/glob-matcher' import directoryLister from '../../../../../agents/file-explorer/directory-lister' import basher from '../../../../../agents/basher' @@ -435,23 +434,23 @@ describe('getAgentPrompt', () => { spawnerPrompt: 'Spawn to find relevant files in a codebase', }) - const codeSearcherTemplate = createMockAgentTemplate({ - id: 'code-searcher', - displayName: 'Code Searcher', - spawnerPrompt: 'Mechanically runs multiple code search queries', + const globMatcherTemplate = createMockAgentTemplate({ + id: 'glob-matcher', + displayName: 'Glob Matcher', + spawnerPrompt: 'Mechanically runs multiple glob pattern matches', }) const mainAgentTemplate = createMockAgentTemplate({ id: 'main-agent', displayName: 'Main Agent', - spawnableAgents: ['file-picker', 'code-searcher'], + spawnableAgents: ['file-picker', 'glob-matcher'], instructionsPrompt: 'Main agent instructions.', }) const agentTemplates: Record = { 'main-agent': mainAgentTemplate, 'file-picker': filePickerTemplate, - 'code-searcher': codeSearcherTemplate, + 'glob-matcher': globMatcherTemplate, } const result = await getAgentPrompt({ @@ -473,7 +472,7 @@ describe('getAgentPrompt', () => { '- file-picker: Spawn to find relevant files in a codebase', ) expect(result).toContain( - '- code-searcher: Mechanically runs multiple code search queries', + '- glob-matcher: Mechanically runs multiple glob pattern matches', ) }) @@ -860,7 +859,6 @@ describe('getAgentPrompt', () => { librarian, dependencyManager, securityReviewer, - codeSearcher, globMatcher, directoryLister, basher, diff --git a/packages/agent-runtime/src/tools/handlers/tool/check-background-agent.ts b/packages/agent-runtime/src/tools/handlers/tool/check-background-agent.ts index f7a063eed9..f181366639 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/check-background-agent.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/check-background-agent.ts @@ -1,6 +1,9 @@ import { + advanceBackgroundAgentConsumerCursor, assertBackgroundAgentJobOwned, + BACKGROUND_AGENT_CANCEL_REASON, cancelBackgroundAgentJob, + getBackgroundAgentConsumerCursor, getBackgroundAgentJob, getBackgroundAgentJobCore, snapshotBackgroundAgentJob, @@ -18,6 +21,51 @@ import type { JobEvent } from '@codebuff/common/util/job-registry' type ToolName = 'check_background_agent' +/** + * Deadline applied to a follow-mode call that asked to wait (`wait_for`) without + * an explicit `timeout_seconds`. Follow mode must ALWAYS have a deadline: + * awaiting the registry with none would let one tool call block the agent turn + * indefinitely, contradicting the tool's documented `timeout_seconds: 0` + * (return-immediately) behavior. Exported so the covering test pins the bound + * instead of hard-coding it. + */ +export const DEFAULT_CHECK_BACKGROUND_AGENT_FOLLOW_TIMEOUT_MS = 30_000 + +/** + * Hard ceiling on any follow-mode wait, mirroring the input schema's 600-second + * maximum so a caller that reaches this handler through a non-validating path + * still cannot hold the turn open longer than the documented maximum. + */ +export const MAX_CHECK_BACKGROUND_AGENT_FOLLOW_TIMEOUT_MS = 600_000 + +/** + * Resolve whether a call joins (follow mode) and the deadline that join runs + * under. Follow mode ALWAYS gets a finite deadline: awaiting the registry with + * none would let one tool call block the agent turn indefinitely, contradicting + * the tool's documented `timeout_seconds: 0` (return-immediately) behavior. A + * non-finite or negative `timeout_seconds` is treated as omitted rather than + * disabling the bound, and every deadline is capped at the documented maximum. + */ +export function resolveCheckBackgroundAgentWaitBounds(params: { + waitFor?: string + timeoutSeconds?: number +}): { follow: boolean; timeoutMs: number } { + const requestedSeconds = params.timeoutSeconds + const requestedTimeoutMs = + typeof requestedSeconds === 'number' && Number.isFinite(requestedSeconds) + ? Math.max(0, requestedSeconds * 1000) + : 0 + return { + follow: Boolean(params.waitFor) || requestedTimeoutMs > 0, + timeoutMs: Math.min( + requestedTimeoutMs > 0 + ? requestedTimeoutMs + : DEFAULT_CHECK_BACKGROUND_AGENT_FOLLOW_TIMEOUT_MS, + MAX_CHECK_BACKGROUND_AGENT_FOLLOW_TIMEOUT_MS, + ), + } +} + /** * Flatten an event's payload into a searchable string for wait_for matching. * agent_chunk payloads are opaque structured events (text, tool_call, @@ -41,16 +89,29 @@ function eventToSearchString(event: JobEvent): string { return chunkType } +/** + * The single not_found message shape, used for an id the unified core no + * longer knows about (never allocated, or reclaimed by the settled-job TTL + * sweep). A job whose adapter view was count-cap evicted is NOT reported this + * way: its lifecycle state and its settled result still live on the core + * record, so it is reported normally. + */ +function jobNotFoundMessage(jobId: string): string { + return `No background agent job found with id "${jobId}".` +} + export const handleCheckBackgroundAgent = (async ({ previousToolCallFinished, toolCall, agentState, clientSessionId, + signal, }: { previousToolCallFinished: Promise toolCall: CodebuffToolCall agentState: AgentState clientSessionId: string + signal: AbortSignal }): Promise<{ output: CodebuffToolOutput }> => { await previousToolCallFinished @@ -78,7 +139,7 @@ export const handleCheckBackgroundAgent = (async ({ owned.reason === 'not_found' ? { jobId, - errorMessage: `No background agent job found with id "${jobId}".`, + errorMessage: jobNotFoundMessage(jobId), } : { jobId, @@ -90,6 +151,13 @@ export const handleCheckBackgroundAgent = (async ({ } // cancel:true maps to the registry's cancel + the adapter's AbortController. + // A repeat cancel on an already-settled job (including one whose settled view + // was count-cap evicted) is an IDEMPOTENT no-op, not an error: the caller + // still asked for this job's state, events, and result, and replacing that + // with an error-only payload would make the retry lose the settled receipt. + // Only an id the unified core no longer knows at all is an error, and the + // ownership gate above already reported that case as not_found. + let cancelledNow = false if (cancel) { const cancelResult = cancelBackgroundAgentJob(jobId) if ('errorMessage' in cancelResult) { @@ -100,35 +168,73 @@ export const handleCheckBackgroundAgent = (async ({ } as unknown as CodebuffToolOutput, } } - const intent = agentState.backgroundAgentJobs?.find( - (entry) => entry.jobId === jobId, - ) - if (intent) { - intent.status = 'cancelled' - intent.completedAt = Date.now() - intent.error = 'Cancelled by check_background_agent.' + cancelledNow = cancelResult.cancelled + if (cancelledNow) { + const intent = agentState.backgroundAgentJobs?.find( + (entry) => entry.jobId === jobId, + ) + if (intent) { + intent.status = 'cancelled' + intent.completedAt = Date.now() + intent.error = BACKGROUND_AGENT_CANCEL_REASON + } } } - const timeoutMs = Math.max(0, (timeout_seconds ?? 0) * 1000) + // Follow mode is always bounded, by BOTH a deadline and the turn's abort + // signal, so joining a background agent can neither block nor leak the turn. + // A `wait_for` without an explicit timeout uses the documented default bound + // instead of awaiting the registry forever. const predicate = wait_for ? (event: JobEvent) => eventToSearchString(event).includes(wait_for) : undefined - const follow = Boolean(wait_for) || timeoutMs > 0 + const { follow, timeoutMs } = resolveCheckBackgroundAgentWaitBounds({ + waitFor: wait_for, + timeoutSeconds: timeout_seconds, + }) + + // The tool documents "immediate chunks since cursor (or last poll if + // omitted)", so a poll that omits `cursor` resolves from THIS consumer's last + // confirmed position instead of the core's default 0 — which would + // re-deliver the entire retained buffer on every poll and report + // `truncated: true` forever once eviction started. A supplied cursor is + // honored exactly (including an explicit 0 = replay from the beginning) and + // never moves the stored position. The consumer key is the polling identity + // resolved above (session + root run + polling agent), so two pollers of one + // job cannot steal each other's place, and the store is per job and bounded + // by the adapter's MAX_CONSUMER_CURSORS. + const consumerId = `${resolved.clientSessionId}:${resolved.rootRunId}:${resolved.parentAgentId}` + const cursorOmitted = cursor === undefined + const effectiveCursor = cursorOmitted + ? getBackgroundAgentConsumerCursor(jobId, consumerId) + : cursor // Join/wait over the unified core event stream. Poll mode resolves // immediately from the snapshot; follow mode is driven off the registry's - // internal notifications (no sleep-polling). + // internal notifications (no sleep-polling). The cursor is clamped to the + // job's latest sequence by the core, so a cursor past the end can never + // leave the terminal transition unable to settle the wait. const result = follow ? await waitForBackgroundAgentJob(jobId, { - cursor, + cursor: effectiveCursor, predicate, - timeoutMs: timeoutMs > 0 ? timeoutMs : undefined, + timeoutMs, + signal, }) - : (snapshotBackgroundAgentJob(jobId, cursor) ?? undefined) + : (snapshotBackgroundAgentJob(jobId, effectiveCursor) ?? undefined) const events = result?.events ?? [] - const nextCursor = result?.nextCursor ?? cursor ?? 0 + // Falls back to 0 rather than echoing the caller's cursor: the owned job + // always yields a result here, and reporting a cursor the core did not + // confirm could pin a consumer past every future event. + const nextCursor = result?.nextCursor ?? 0 + // Only a cursorless poll owns the stored position, and it advances only as + // far as the core confirmed: a follow-mode timeout that returned no events + // confirms the cursor it started from, so events that land later are still + // delivered to this consumer. + if (cursorOmitted) { + advanceBackgroundAgentConsumerCursor(jobId, consumerId, nextCursor) + } const state = result?.state ?? owned.job.state const dropped = result?.dropped ?? 0 const truncated = @@ -136,12 +242,19 @@ export const handleCheckBackgroundAgent = (async ({ const matched = predicate ? events.some(predicate) : undefined const timedOut = result && 'timedOut' in result ? result.timedOut : false - // The settled result/error comes from the core Job view (the adapter's - // completion handler stamps result; error is folded into the lifecycle). + // Both the settled error and the settled result are folded into the core + // lifecycle, so they are resolved from the core first and fall back to the + // adapter view. That keeps the reported `state` and `result` on the same + // source of truth even when the count-cap sweep has already evicted the + // view of this settled job (the view is only a live mirror; a job that is + // still cancellable never has its view evicted). const coreJob = getBackgroundAgentJobCore(jobId) const view = getBackgroundAgentJob(jobId) - const cancelled = cancel || state === 'cancelled' - const resultValue = view?.result + // Only a cancel that actually took effect (or a job already cancelled) + // reports `cancelled`: an idempotent repeat cancel on a completed job must + // not relabel that job's terminal outcome. + const cancelled = cancelledNow || state === 'cancelled' + const resultValue = coreJob?.result ?? view?.result const errorValue = coreJob?.error ?? view?.error return { diff --git a/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts b/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts index 158b32b6ba..ed940e76f3 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts @@ -149,9 +149,12 @@ export function extractSubagentContextParams( } } -const SPAWN_AGENT_TYPE_ALIASES: Readonly> = { - 'code-searccher': 'code-searcher', -} +/** + * Known agent-name typo corrections applied before permission checks and + * template lookup. Currently empty; keep the table so restoring a narrow alias + * stays a one-line change. + */ +const SPAWN_AGENT_TYPE_ALIASES: Readonly> = {} /** * Canonicalizes known agent-name typos before permission checks and template @@ -546,6 +549,24 @@ const REVIEWER_EVIDENCE_CHARS = 360 const REVIEWER_REQUIREMENT_EVIDENCE_LIMIT = 2 const PARENT_AGENT_OUTPUT_MAX_CHARS = 256_000 const PARENT_AGENT_OUTPUT_STRING_CHARS = 4_000 +/** + * Answer-bearing fields get a much larger cap than incidental metadata: these + * are the channels a child agent actually reports through (assistant text, + * set_output summaries, captured command output), so clipping them at the + * default 4k is what made long basher logs and long agent answers arrive + * truncated. Everything else keeps the default cap. + */ +const PARENT_AGENT_OUTPUT_HIGH_FIDELITY_STRING_CHARS = 32_000 +const HIGH_FIDELITY_STRING_FIELDS = new Set([ + 'text', + 'message', + 'summary', + 'answer', + 'report', + 'digest', + 'stdout', + 'stderr', +]) const PARENT_AGENT_OUTPUT_ARRAY_ITEMS = 48 const CONTROL_PLANE_ARRAY_FIELDS = new Set([ 'reviewedFiles', @@ -640,7 +661,9 @@ function compactAgentOutputValue( if (typeof value === 'string') { const compacted = truncateReviewerText( value, - PARENT_AGENT_OUTPUT_STRING_CHARS, + fieldName && HIGH_FIDELITY_STRING_FIELDS.has(fieldName) + ? PARENT_AGENT_OUTPUT_HIGH_FIDELITY_STRING_CHARS + : PARENT_AGENT_OUTPUT_STRING_CHARS, ) if (typeof compacted === 'string') { truncation.omittedChars += Math.max(0, value.length - compacted.length) @@ -1297,15 +1320,20 @@ export function buildRuntimeAgentReceipt(params: { typeof shardId === 'string' && shardId.trim().length > 0 && trimmedSnapshotId.length > 0 + // Credit the generator harvest from the harvest flag alone: the harvest + // never sets AgentState.consecutiveTextOnlyWithoutCompletion, and the + // step-cap early return never touches it either, so requiring that counter + // suppressed the retryable 'no task_completed' error on one exit path and not + // the other. Only a harvest that recovered REAL answer text may stand in for + // explicit completion: an answerless / step-capped run marks its output with + // noHarvestedAnswer, whose summary is just a placeholder, so it stays a + // retryable partial the parent can re-spawn instead of completed with zero + // errors. + const harvestedOutput = params.agentState?.output const hasHarvestedFallback = - (params.agentState as unknown as Record) - ?.consecutiveTextOnlyWithoutCompletion !== undefined && - (params.agentState as unknown as Record)?.output !== - undefined && - ( - (params.agentState as unknown as Record) - ?.output as Record - )?.harvestedFromFallback === true + harvestedOutput !== undefined && + harvestedOutput.harvestedFromFallback === true && + harvestedOutput.noHarvestedAnswer !== true const hasExplicitCompletionOrHarvest = containsToolCall(receiptSources, 'task_completed') || hasHarvestedFallback const missingExplicitCompletion = @@ -1764,10 +1792,7 @@ export function validateAgentInput( : normalizedAgentType === 'librarian' && issuePaths.has('repoUrl') ? '\n\nRecovery: set params.repoUrl to a GitHub URL, for example { "agent_type": "librarian", "params": { "repoUrl": "https://github.com//" } }. params.repoUrl must have the form https://github.com//; a URL only in prompt prose is not used.' - : normalizedAgentType === 'code-searcher' && - issuePaths.has('searchQueries') - ? '\n\nRecovery: spawn code-searcher with { "agent_type": "code-searcher", "params": { "searchQueries": [{ "pattern": "", "flags": "-g *.ts" }] } }. searchQueries is a required array of objects each with a non-empty string "pattern"; queries mentioned only in prompt prose are never executed.' - : '' + : '' const paramsContract = formatAgentParamsContract(inputSchema.params) throw new Error( `Invalid params for agent ${agentType}: ${formatValidationIssues({ issues: result.error.issues })}\n\nExact params contract (from the child agent schema): ${paramsContract}\nPreserve params field names exactly.${recoveryHint}\n\nOriginal params value:\n${formatValueForError(params ?? {})}`, diff --git a/packages/agent-runtime/src/tools/handlers/tool/spawn-agents.ts b/packages/agent-runtime/src/tools/handlers/tool/spawn-agents.ts index 6fc596e2e5..35624958a5 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/spawn-agents.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/spawn-agents.ts @@ -2,10 +2,13 @@ import { jsonToolResult } from '@codebuff/common/util/messages' import { MAX_SPAWN_BATCH_SIZE } from '@codebuff/common/constants/agents' import { - allocateBackgroundAgentJob, + abandonPreLaunchBackgroundAgentJob, + allocateBackgroundAgentJobBatch, appendBackgroundAgentChunk, - assertBackgroundAgentCapacity, attachBackgroundAgentPromise, + BACKGROUND_AGENT_CANCEL_REASON, + backgroundAgentJobWasCancelled, + reconcileInterruptedBackgroundAgentIntents, } from '../../../util/background-agent-jobs' import { @@ -33,6 +36,7 @@ import { completeDiscoveryShard, } from '../../../orchestration/discovery-coordinator' +import type { BackgroundAgentJob } from '../../../util/background-agent-jobs' import type { CodebuffToolHandlerFunction } from '../handler-function-type' import type { CodebuffToolCall, @@ -132,6 +136,14 @@ export const handleSpawnAgents = (async ( ) } + // The parent's durable background-job intents double as the background + // concurrency budget (`runningForRoot` below), so reconcile them against the + // live registry HERE too — not only at loopAgentSteps entry. Otherwise a job + // that vanished mid-turn keeps a 'running' intent for the rest of the turn + // and can reject a later legitimate background spawn. Same field writes as + // the turn-entry pass and idempotent, so a retried spawn behaves identically. + reconcileInterruptedBackgroundAgentIntents(parentAgentState) + // Validate the complete batch before launching any detached work. Without // this preflight, an invalid later entry could throw after earlier // background agents had started but before their job ids were returned. @@ -224,29 +236,13 @@ export const handleSpawnAgents = (async ( const reports: Array = new Array( validatedAgents.length, ) - const backgroundAgentCount = validatedAgents.filter( + const backgroundAgents = validatedAgents.filter( (validated) => validated.input.background, - ).length - if (backgroundAgentCount > 0) { - assertBackgroundAgentCapacity({ - additional: backgroundAgentCount, - owner: { - clientSessionId: params.clientSessionId, - rootRunId: - parentAgentState.ancestorRunIds[0] ?? - parentAgentState.runId ?? - parentAgentState.agentId, - parentRunId: parentAgentState.runId ?? parentAgentState.agentId, - parentAgentId: parentAgentState.agentId, - userInputId, - }, - }) - } + ) let nextDiscoveryCoverage = parentAgentState.discoveryCoverage for (const validated of validatedAgents) { if ( validated.agentType === 'file-picker' || - validated.agentType === 'code-searcher' || validated.agentType === 'file-lister' ) { const claimed = claimDiscoveryShard({ @@ -268,6 +264,42 @@ export const handleSpawnAgents = (async ( // parent state remains unchanged instead of retaining an active shard for an // agent that was never launched. parentAgentState.discoveryCoverage = nextDiscoveryCoverage + // Spawns whose `spawn_started` ledger event has already been emitted. A + // rejected batch must terminate them, or the control-plane ledger keeps + // reporting spawns that were never launched as in-flight for the rest of the + // turn. + const startedSpawnIds = new Set() + // Release everything the batch already claimed when a whole-batch step + // fails, so a rejected batch never leaves an active workspace path lease, an + // active discovery shard, or an unterminated `spawn_started` behind for an + // agent that was never launched. + const rollbackValidatedClaims = (reason: string) => { + for (const validated of validatedAgents) { + releaseWorkspacePathLease(parentAgentState, validated.leaseId) + parentAgentState.discoveryCoverage = completeDiscoveryShard({ + existing: parentAgentState.discoveryCoverage, + shardKey: validated.discoveryShardKey, + status: 'interrupted', + }) + // `interrupted` is the terminal marker reconcileInterruptedLedgerSpawns + // recognizes, so a spawn that never launched settles in the ledger + // instead of staying pending. Emitted at most once per started spawn. + if (startedSpawnIds.delete(validated.subAgentState.agentId)) { + appendOrchestrationEvent({ + state: parentAgentState, + event: { + type: 'interrupted', + runId: parentAgentState.runId ?? parentAgentState.agentId, + subjectType: 'spawn', + subjectId: validated.subAgentState.agentId, + reason, + workspaceRevision: parentAgentState.workspaceState?.revision, + workspaceSnapshotId: parentAgentState.workspaceState?.snapshotId, + }, + }) + } + } + } try { for (const validated of validatedAgents) { validated.leaseId = acquireWorkspacePathLease({ @@ -279,14 +311,9 @@ export const handleSpawnAgents = (async ( }) } } catch (error) { - for (const validated of validatedAgents) { - releaseWorkspacePathLease(parentAgentState, validated.leaseId) - parentAgentState.discoveryCoverage = completeDiscoveryShard({ - existing: parentAgentState.discoveryCoverage, - shardKey: validated.discoveryShardKey, - status: 'interrupted', - }) - } + rollbackValidatedClaims( + 'Workspace path lease acquisition failed before the spawn was launched.', + ) throw error } for (const validated of validatedAgents) { @@ -304,193 +331,280 @@ export const handleSpawnAgents = (async ( workspaceSnapshotId: parentAgentState.workspaceState?.snapshotId, }, }) + startedSpawnIds.add(validated.subAgentState.agentId) } - for (const validated of validatedAgents) { - if (!validated.input.background) continue - const { - agentTemplate, - agentType, - runtimeSpawnParams, - subAgentState, - spawnIndex, - } = validated - const { prompt, timeout_seconds } = validated.input + // Claim the whole background batch's capacity in ONE atomic check that also + // pre-allocates every job id: a concurrent spawn can no longer land between + // a batch preflight and a per-job allocation and reject mid-batch after + // earlier coroutines were already launched. Either every id exists (and + // every coroutine below is launched), or nothing is launched and the leases + // and discovery shards taken for the validated batch are rolled back. + // Pre-allocation is also required because executeSubagent fires + // onResponseChunk(startEvent) synchronously — before it returns the + // coroutine promise — so each chunk handler needs a valid jobId already. + let backgroundJobs: BackgroundAgentJob[] = [] + if (backgroundAgents.length > 0) { + try { + backgroundJobs = allocateBackgroundAgentJobBatch({ + agents: backgroundAgents.map((validated) => ({ + agentType: validated.agentType, + agentName: validated.agentTemplate.displayName, + })), + owner: { + clientSessionId: params.clientSessionId, + rootRunId: + parentAgentState.ancestorRunIds[0] ?? + parentAgentState.runId ?? + parentAgentState.agentId, + parentRunId: parentAgentState.runId ?? parentAgentState.agentId, + parentAgentId: parentAgentState.agentId, + userInputId, + }, + }) + } catch (error) { + rollbackValidatedClaims( + 'Background agent job allocation failed before the spawn was launched.', + ) + throw error + } + } + // Every job allocated above is already 'running' in the registry while still + // holding only the allocation placeholder promise: no settle handler is wired + // until attachBackgroundAgentPromise below, and nothing reaps background + // agent jobs. A throw inside this loop (extractSubagentContextParams, + // createCombinedAbortSignal, or a synchronous executeSubagent throw) would + // therefore strand the not-yet-launched jobs 'running' forever, permanently + // consuming the process-wide (32) and per-root (8) background budget. Track + // the jobs whose coroutine WAS launched — those keep normal settle handling + // and must never be abandoned — and terminally abandon only the rest. + const wiredBackgroundJobIds = new Set() + try { + for (const [backgroundIndex, validated] of backgroundAgents.entries()) { + const { + agentTemplate, + agentType, + runtimeSpawnParams, + subAgentState, + spawnIndex, + } = validated + const { prompt, timeout_seconds } = validated.input - const contextParams = extractSubagentContextParams(params) + const contextParams = extractSubagentContextParams(params) - // Pre-allocate the jobId so executeSubagent's synchronous - // onResponseChunk(startEvent) callback has a valid jobId to buffer into. - // executeSubagent fires the start event before it even returns the - // coroutine promise, so we cannot register-after-allocate here. - const job = allocateBackgroundAgentJob({ - agentType, - agentName: agentTemplate.displayName, - owner: { - clientSessionId: params.clientSessionId, - rootRunId: - parentAgentState.ancestorRunIds[0] ?? - parentAgentState.runId ?? - parentAgentState.agentId, - parentRunId: parentAgentState.runId ?? parentAgentState.agentId, - parentAgentId: parentAgentState.agentId, - userInputId, - }, - }) - const backgroundSignal = contextParams.signal - ? createCombinedAbortSignal( - contextParams.signal, - job.abortController.signal, - ) - : job.abortController.signal - parentAgentState.backgroundAgentJobs ??= [] - parentAgentState.backgroundAgentJobs.push({ - jobId: job.jobId, - agentType, - status: 'running', - startedAt: job.startedAt, - }) + // jobId pre-allocated with the rest of the batch above. + const job = backgroundJobs[backgroundIndex] + // Keep the combined signal in its own binding: it installs an abort + // listener on BOTH inputs, and neither fires when the job settles + // normally, so its cleanup() must run on settle or that listener (plus a + // closure over this job's AbortController) stays attached to the + // long-lived parent signal for the rest of the run. Mirrors + // executeSubagent's `finally { combinedSignal?.cleanup?.() }`. + const combinedSignal = contextParams.signal + ? createCombinedAbortSignal( + contextParams.signal, + job.abortController.signal, + ) + : undefined + const backgroundSignal = combinedSignal ?? job.abortController.signal + parentAgentState.backgroundAgentJobs ??= [] + parentAgentState.backgroundAgentJobs.push({ + jobId: job.jobId, + agentType, + status: 'running', + startedAt: job.startedAt, + }) - // Detached coroutine: do NOT await. The unified job-registry core (via - // the background-agent adapter) is the source of truth for lifecycle - // and the buffered chunk stream that check_background_agent polls; the - // adapter owns only this job's AbortController and capacity limits. - const detachedPromise = executeSubagent({ - ...contextParams, - signal: backgroundSignal, - ancestorRunIds: parentAgentState.ancestorRunIds, - userInputId: `${userInputId}-${agentType}${subAgentState.agentId}`, - prompt: prompt || '', - spawnParams: runtimeSpawnParams, - agentTemplate, - parentAgentState, - agentState: subAgentState, - fingerprintId, - spawnToolCallId: toolCall.toolCallId, - spawnIndex, - // Per-spawn wall-clock override (seconds → ms; -1 → no timeout). - subagentTimeoutMs: - timeout_seconds === undefined ? undefined : timeout_seconds * 1000, - // Background agents are detached; the parent never waits for them, so - // the "only child" step-count semantics (tuned for blocking spawns the - // parent blocks on) never apply. Force false regardless of how many - // agents are in the batch. - isOnlyChild: false, - excludeToolFromMessageHistory: false, - fromHandleSteps: false, - parentSystemPrompt, - parentTools: agentTemplate.inheritParentSystemPrompt - ? parentTools - : undefined, - onResponseChunk: (chunk: string | PrintModeEvent) => { - // Buffer the chunk for polling. We do NOT forward background agent - // chunks to writeToClient/sendSubagentChunk because the parent has - // already moved past this tool call — surfacing interleaved output - // would confuse the active turn. - if (typeof chunk === 'string') { + // Detached coroutine: do NOT await. The unified job-registry core (via + // the background-agent adapter) is the source of truth for lifecycle + // and the buffered chunk stream that check_background_agent polls; the + // adapter owns only this job's AbortController and capacity limits. + const detachedPromise = executeSubagent({ + ...contextParams, + signal: backgroundSignal, + ancestorRunIds: parentAgentState.ancestorRunIds, + userInputId: `${userInputId}-${agentType}${subAgentState.agentId}`, + prompt: prompt || '', + spawnParams: runtimeSpawnParams, + agentTemplate, + parentAgentState, + agentState: subAgentState, + fingerprintId, + spawnToolCallId: toolCall.toolCallId, + spawnIndex, + // Per-spawn wall-clock override (seconds → ms; -1 → no timeout). + subagentTimeoutMs: + timeout_seconds === undefined ? undefined : timeout_seconds * 1000, + // Background agents are detached; the parent never waits for them, so + // the "only child" step-count semantics (tuned for blocking spawns the + // parent blocks on) never apply. Force false regardless of how many + // agents are in the batch. + isOnlyChild: false, + excludeToolFromMessageHistory: false, + fromHandleSteps: false, + parentSystemPrompt, + parentTools: agentTemplate.inheritParentSystemPrompt + ? parentTools + : undefined, + onResponseChunk: (chunk: string | PrintModeEvent) => { + // Buffer the chunk for polling. We do NOT forward background agent + // chunks to writeToClient/sendSubagentChunk because the parent has + // already moved past this tool call — surfacing interleaved output + // would confuse the active turn. + if (typeof chunk === 'string') { + appendBackgroundAgentChunk(job.jobId, { + type: 'text', + payload: chunk, + timestamp: Date.now(), + }) + return + } appendBackgroundAgentChunk(job.jobId, { - type: 'text', + type: chunk.type, payload: chunk, timestamp: Date.now(), }) - return - } - appendBackgroundAgentChunk(job.jobId, { - type: chunk.type, - payload: chunk, - timestamp: Date.now(), - }) - }, - }) + }, + }) - attachBackgroundAgentPromise( - job, - detachedPromise - .then((result) => { - const receipt = buildRuntimeAgentReceipt({ - agentType, - agentId: result.agentState.agentId, - handoff: validated.handoff, - spawnParams: validated.runtimeSpawnParams, - output: result.output, - agentState: result.agentState, - }) - reconcileAgentReceiptIntoParent({ - parentAgentState, - receipt, - agentType, - objective: validated.handoff?.objective, - }) - const intent = parentAgentState.backgroundAgentJobs?.find( - (entry) => entry.jobId === job.jobId, - ) - if (intent) { - intent.status = 'completed' - intent.completedAt = Date.now() - intent.childRunId = result.agentState.runId - intent.receipt = receipt - } - releaseWorkspacePathLease(parentAgentState, validated.leaseId) - parentAgentState.discoveryCoverage = completeDiscoveryShard({ - existing: parentAgentState.discoveryCoverage, - shardKey: validated.discoveryShardKey, - status: 'completed', - }) - return { - agentId: result.agentState.agentId, - agentName: agentTemplate.displayName, - agentType, - output: receipt.output, - agentReceipt: receipt, - creditsUsed: result.agentState.creditsUsed || 0, - } - }) - .catch((error) => { - const receipt = buildRuntimeAgentReceipt({ - agentType, - agentId: subAgentState.agentId, - handoff: validated.handoff, - spawnParams: validated.runtimeSpawnParams, - output: undefined, - agentState: subAgentState, - status: 'failed', - error, - }) - reconcileAgentReceiptIntoParent({ - parentAgentState, - receipt, - agentType, - objective: validated.handoff?.objective, - }) - const intent = parentAgentState.backgroundAgentJobs?.find( - (entry) => entry.jobId === job.jobId, - ) - if (intent) { - intent.status = 'error' - intent.completedAt = Date.now() - intent.error = - error instanceof Error ? error.message : String(error) - intent.receipt = receipt - } - releaseWorkspacePathLease(parentAgentState, validated.leaseId) - parentAgentState.discoveryCoverage = completeDiscoveryShard({ - existing: parentAgentState.discoveryCoverage, - shardKey: validated.discoveryShardKey, - status: 'failed', + attachBackgroundAgentPromise( + job, + detachedPromise + .then((result) => { + // The coroutine settled: detach the combined signal's abort + // listeners from the long-lived parent signal (idempotent, and a + // no-op when there was no parent signal to combine). + combinedSignal?.cleanup?.() + const receipt = buildRuntimeAgentReceipt({ + agentType, + agentId: result.agentState.agentId, + handoff: validated.handoff, + spawnParams: validated.runtimeSpawnParams, + output: result.output, + agentState: result.agentState, + }) + reconcileAgentReceiptIntoParent({ + parentAgentState, + receipt, + agentType, + objective: validated.handoff?.objective, + }) + const intent = parentAgentState.backgroundAgentJobs?.find( + (entry) => entry.jobId === job.jobId, + ) + if (intent) { + intent.status = 'completed' + intent.completedAt = Date.now() + intent.childRunId = result.agentState.runId + intent.receipt = receipt + } + releaseWorkspacePathLease(parentAgentState, validated.leaseId) + parentAgentState.discoveryCoverage = completeDiscoveryShard({ + existing: parentAgentState.discoveryCoverage, + shardKey: validated.discoveryShardKey, + status: 'completed', + }) + return { + agentId: result.agentState.agentId, + agentName: agentTemplate.displayName, + agentType, + output: receipt.output, + agentReceipt: receipt, + creditsUsed: result.agentState.creditsUsed || 0, + } }) - throw error - }), - ) + .catch((error) => { + // Same detach on the failure/cancellation path. + combinedSignal?.cleanup?.() + // A rejection driven by THIS job's own + // check_background_agent({ cancel: true }) abort is a + // cancellation, not a failure: the registry already recorded + // 'cancelled', so stamping 'error'/'failed' onto the parent's + // durable intent and receipt would make one job report two + // different terminal outcomes. Ordinary rejections — including + // subagent timeouts and a parent-signal abort — keep the + // error/failed path unchanged. + const cancelled = backgroundAgentJobWasCancelled(job) + const receipt = buildRuntimeAgentReceipt({ + agentType, + agentId: subAgentState.agentId, + handoff: validated.handoff, + spawnParams: validated.runtimeSpawnParams, + output: undefined, + agentState: subAgentState, + status: cancelled ? 'cancelled' : 'failed', + // Any `error` is folded into the receipt's `errors`, which forces + // status 'failed', so the cancelled case requests 'cancelled' + // with no error and carries the reason on the intent instead. + error: cancelled ? undefined : error, + }) + reconcileAgentReceiptIntoParent({ + parentAgentState, + receipt, + agentType, + objective: validated.handoff?.objective, + }) + const intent = parentAgentState.backgroundAgentJobs?.find( + (entry) => entry.jobId === job.jobId, + ) + if (intent) { + intent.status = cancelled ? 'cancelled' : 'error' + intent.completedAt = Date.now() + // Keep the cancellation reason check_background_agent already + // recorded; fall back to the adapter's canonical reason. + intent.error = cancelled + ? (intent.error ?? BACKGROUND_AGENT_CANCEL_REASON) + : error instanceof Error + ? error.message + : String(error) + intent.receipt = receipt + } + releaseWorkspacePathLease(parentAgentState, validated.leaseId) + parentAgentState.discoveryCoverage = completeDiscoveryShard({ + existing: parentAgentState.discoveryCoverage, + shardKey: validated.discoveryShardKey, + status: 'failed', + }) + throw error + }), + ) + // Wired: this job's coroutine is launched and its settle handlers own + // the terminal transition from here on, so it must never be abandoned. + wiredBackgroundJobIds.add(job.jobId) - reports[spawnIndex] = { - agentId: subAgentState.agentId, - agentName: agentTemplate.displayName, - agentType, - value: { - background: true, - jobId: job.jobId, - message: `Agent launched in background. Poll progress with check_background_agent({ jobId: "${job.jobId}" }).`, - } as JSONValue, + reports[spawnIndex] = { + agentId: subAgentState.agentId, + agentName: agentTemplate.displayName, + agentType, + value: { + background: true, + jobId: job.jobId, + message: `Agent launched in background. Poll progress with check_background_agent({ jobId: "${job.jobId}" }).`, + } as JSONValue, + } } + } catch (error) { + const abandonReason = + 'Background agent spawn failed before its coroutine was launched.' + for (const job of backgroundJobs) { + if (wiredBackgroundJobIds.has(job.jobId)) continue + abandonPreLaunchBackgroundAgentJob(job, abandonReason) + // The durable intent doubles as the per-root background budget, so an + // abandoned job's intent must settle here too instead of counting as + // 'running' for the rest of the turn. Jobs whose coroutine WAS launched + // keep their intent and their settle handlers, untouched. + const intent = parentAgentState.backgroundAgentJobs?.find( + (entry) => entry.jobId === job.jobId, + ) + if (intent && intent.status === 'running') { + intent.status = 'error' + intent.completedAt = Date.now() + intent.error = abandonReason + } + } + rollbackValidatedClaims( + 'Background agent launch failed before the spawn was launched.', + ) + throw error } const foregroundAgents = validatedAgents.filter( diff --git a/packages/agent-runtime/src/tools/tool-executor.ts b/packages/agent-runtime/src/tools/tool-executor.ts index 46ae358575..05bceb97eb 100644 --- a/packages/agent-runtime/src/tools/tool-executor.ts +++ b/packages/agent-runtime/src/tools/tool-executor.ts @@ -1095,9 +1095,8 @@ export function buildUnavailableToolMessage(params: { toolName: string agentId: string availableTools: string[] - input?: unknown }): string { - const { toolName, agentId, availableTools, input } = params + const { toolName, agentId, availableTools } = params const availableList = availableTools.length > 0 ? availableTools.map((name) => `\`${name}\``).join(', ') @@ -1115,29 +1114,17 @@ export function buildUnavailableToolMessage(params: { // granted. Point the model at the granted tools / spawnable agents instead // of letting it guess another unavailable name. if ((toolNames as readonly string[]).includes(toolName)) { - // Concrete recovery for content-search tools: prefer direct code_search - // when already granted; otherwise point at the code-searcher spawn recipe. - // 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. + // Concrete recovery for content-search tools: when `code_search` is already + // granted, point at calling it directly, once per pattern. An ungranted + // content-search tool falls through to the generic registered-but-not- + // granted guidance below. This stays message-only; the tool remains + // fail-closed and nothing is auto-spawned. if ( - toolName === 'code_search' || - toolName === 'find_files_matching_content' + (toolName === 'code_search' || + toolName === 'find_files_matching_content') && + availableTools.includes('code_search') ) { - 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.` - } - const inputPattern = - input !== null && - typeof input === 'object' && - !Array.isArray(input) && - typeof (input as Record).pattern === 'string' && - ((input as Record).pattern as string).trim() !== '' - ? ((input as Record).pattern as string) - : undefined - const patternJson = - inputPattern !== undefined ? JSON.stringify(inputPattern) : '""' - return `${base} \`${toolName}\` is a registered tool but is not granted to this agent; spawn the code-searcher agent instead: { "agent_type": "code-searcher", "params": { "searchQueries": [{ "pattern": ${patternJson}, "flags": "-g *.ts" }] } }.` + return `${base} Use the granted \`code_search\` tool directly (pattern/flags/cwd/maxResults). For several patterns, issue one \`code_search\` call per pattern.` } return `${base} \`${toolName}\` is a registered tool but is not granted to this agent; use one of the available tools above, or spawn an agent that provides that capability.` } @@ -1260,7 +1247,7 @@ function getToolValidationHint( const base = [ 'Expected shape: { "agents": [{ "agent_type": string, "prompt"?: string, "params"?: object, "handoff"?: object }] }.', 'Pass agents as an array of objects. `prompt`, `params`, and `handoff` must be inside each agent object; check every brace and bracket when a field appears misplaced. Valid stringified or double-stringified JSON is repaired automatically, but ambiguous brace nesting, truncated JSON, and non-object entries are rejected without guessing or auto-repair. Do not stringify each agent entry.', - 'Corrected example: { "agents": [{ "agent_type": "code-searcher", "prompt": "", "params": { "searchQueries": [{ "pattern": "" }] } }] } — note prompt/params live INSIDE each agent object, and agents is a real array, not a JSON string.', + 'Corrected example: { "agents": [{ "agent_type": "basher", "prompt": "", "params": { "command": "" } }] } — note prompt/params live INSIDE each agent object, and agents is a real array, not a JSON string.', ].join('\n') const hasHandoffIssue = (issues ?? []).some(isSpawnAgentHandoffIssue) if (!hasHandoffIssue) return base @@ -1973,7 +1960,6 @@ export async function executeToolCall( toolName, agentId: agentTemplate.id, availableTools: getEffectiveAgentToolNames(agentTemplate), - input, }), }) return abortablePreviousToolCallFinished @@ -2005,7 +1991,6 @@ export async function executeToolCall( toolName, agentId: agentTemplate.id, availableTools, - input, }), }) return abortablePreviousToolCallFinished @@ -3202,7 +3187,6 @@ export async function executeCustomToolCall( toolName, agentId: agentTemplate.id, availableTools, - input, }), }) return abortablePreviousToolCallFinished diff --git a/packages/agent-runtime/src/util/__tests__/parse-tool-calls-from-text.test.ts b/packages/agent-runtime/src/util/__tests__/parse-tool-calls-from-text.test.ts index aca8b1d078..19d05f5e72 100644 --- a/packages/agent-runtime/src/util/__tests__/parse-tool-calls-from-text.test.ts +++ b/packages/agent-runtime/src/util/__tests__/parse-tool-calls-from-text.test.ts @@ -154,10 +154,10 @@ Some commentary after` "prompt": "Find relevant files" }, { - "agent_type": "code-searcher", + "agent_type": "general-agent", "params": { - "searchQueries": [ - {"pattern": "function test"} + "filePaths": [ + "src/test.ts" ] } } diff --git a/packages/agent-runtime/src/util/background-agent-jobs.ts b/packages/agent-runtime/src/util/background-agent-jobs.ts index 172ff7b505..7903662a1e 100644 --- a/packages/agent-runtime/src/util/background-agent-jobs.ts +++ b/packages/agent-runtime/src/util/background-agent-jobs.ts @@ -21,8 +21,12 @@ * coroutine settle/cancel into core `lifecycle` events. * * Preserved bounds: 200 buffered events per job (each chunk payload truncated - * to 64KB), a 30-minute settled-job TTL, 100 total jobs, 32 running jobs, 8 - * running jobs per root run. This adapter shares the process-wide + * to 64KB), a 30-minute settled-job TTL, 100 retained adapter views, 32 running + * background agents, 8 running background agents per root run. Every one of + * those bounds is computed over the registry's 'agent'-kind population only (or, + * for the view cap, over this adapter's own views), so shell `process` jobs + * sharing the same registry never consume the background-AGENT budget and never + * distort the view cap. This adapter shares the process-wide * `jobRegistry` singleton — the single source of truth every consumer * reads — and the core enforces the agent bounds (the 200-event ring buffer * and 30-minute settled TTL) per-kind for 'agent' jobs; the state machine, @@ -36,6 +40,7 @@ import { jobRegistry, } from '@codebuff/common/util/job-registry' +import type { AgentState } from '@codebuff/common/types/session-state' import type { AssertOwnedResult, Job, @@ -53,7 +58,14 @@ import type { const MAX_BUFFERED_CHUNKS = 200 const MAX_BUFFERED_CHUNK_BYTES = 64 * 1024 const MAX_CONSUMER_CURSORS = 32 -const MAX_BACKGROUND_AGENT_JOBS = 100 +/** + * Memory bound on the live adapter views this module retains. Evaluated over + * `views` itself rather than over the shared registry population: the registry + * also holds shell `process` jobs (and other owners' jobs), so counting them + * would both trigger the cap when no view needs evicting and leave the cap + * ineffective when the views genuinely grew. + */ +const MAX_BACKGROUND_AGENT_VIEWS = 100 const MAX_RUNNING_BACKGROUND_AGENT_JOBS = 32 const MAX_RUNNING_BACKGROUND_AGENT_JOBS_PER_ROOT = 8 @@ -124,7 +136,7 @@ export interface BackgroundAgentJobOwner extends JobOwner { export interface BackgroundAgentJob { jobId: string - /** Agent type string (e.g. 'basher', 'code-searcher'). */ + /** Agent type string (e.g. 'basher', 'file-picker'). */ agentType: string /** Agent template display name. */ agentName: string @@ -216,59 +228,109 @@ function currentChunks(job: BackgroundAgentJob): BackgroundAgentChunk[] { } /** - * Drop settled jobs past the retention TTL (delegated to the core's sweep) - * and cap the total registry size by evicting the oldest settled jobs. The - * core has no single-job drop API, so count-cap eviction removes the - * adapter's view — making the job invisible to every adapter API — while the - * core record is left for the TTL sweep to reclaim. + * Running jobs of THIS adapter's kind. Concurrency limits are background-AGENT + * limits, so a shell `process` job (dev server, watcher, tail) sharing the + * process-wide registry must never consume that budget — filtering by kind here + * is what keeps a long-lived watcher from blocking every background agent spawn + * for a run. + */ +function listRunningAgentJobs(owner?: { + clientSessionId: string + rootRunId: string +}): Job[] { + return registry + .listRunning(owner) + .filter( + (coreJob) => coreJob.kind === 'agent' && coreJob.state === 'running', + ) +} + +/** + * Drop settled jobs past the retention TTL (delegated to the core's sweep) and + * cap the number of retained ADAPTER VIEWS by evicting the oldest settled ones. + * The count cap is evaluated over `views` and the registry's 'agent'-kind + * records only — never the whole shared registry population — so process jobs + * and other owners' jobs can neither trigger nor defeat the view bound. + * + * The core has no single-job drop API, so count-cap eviction removes only the + * adapter's view (its buffered chunks), while the core record is left for the + * TTL sweep to reclaim. The core is the durable home of the lifecycle state + * AND of the settled `result` (stamped there by + * {@link attachJobCompletionHandlers}), so a job whose view was evicted still + * reports its state and its result to check_background_agent, is still owned, + * and is still an idempotent cancel target — never not_found. + * + * Only SETTLED views are evicted, and that is load-bearing rather than + * incidental: the view owns this job's AbortController, so dropping the view + * of a job whose core state is non-terminal would leave nothing able to cancel + * it. The candidate list below is filtered to terminal core states for exactly + * that reason. */ function sweepBackgroundAgentJobs(): void { - const coreJobs = registry.list() - const liveCoreIds = new Set(coreJobs.map((coreJob) => coreJob.jobId)) + // Only 'agent' records are backed by a view, so this is the exact population + // the view cap bounds. + const agentJobsById = new Map( + registry + .list() + .filter((coreJob) => coreJob.kind === 'agent') + .map((coreJob) => [coreJob.jobId, coreJob] as const), + ) for (const jobId of views.keys()) { - if (!liveCoreIds.has(jobId)) { + if (!agentJobsById.has(jobId)) { views.delete(jobId) } } - if (coreJobs.length <= MAX_BACKGROUND_AGENT_JOBS) return - const settled = coreJobs - .filter((coreJob) => isTerminalJobState(coreJob.state)) + if (views.size <= MAX_BACKGROUND_AGENT_VIEWS) return + // Eviction candidates are exactly the views whose core state is terminal: a + // non-terminal job is still cancellable and its AbortController lives on the + // view, so its view must stay reachable no matter how old it is. + const settled = [...views.keys()] + .flatMap((jobId) => { + const coreJob = agentJobsById.get(jobId) + return coreJob && isTerminalJobState(coreJob.state) ? [coreJob] : [] + }) .sort( (a, b) => (a.completedAt ?? a.startedAt ?? a.createdAt) - (b.completedAt ?? b.startedAt ?? b.createdAt), ) - let count = coreJobs.length + let count = views.size for (const coreJob of settled) { - if (count <= MAX_BACKGROUND_AGENT_JOBS) break + if (count <= MAX_BACKGROUND_AGENT_VIEWS) break views.delete(coreJob.jobId) count -= 1 } } +/** Fall back to the placeholder owner when a caller has no run identity. */ +function resolveBackgroundAgentJobOwner( + owner?: BackgroundAgentJob['owner'], +): BackgroundAgentJob['owner'] { + return ( + owner ?? { + clientSessionId: 'unknown-session', + rootRunId: 'unknown-root', + parentRunId: 'unknown-parent-run', + parentAgentId: 'unknown-parent-agent', + userInputId: 'unknown-input', + } + ) +} + /** - * Allocate a job id and a pending job record WITHOUT a coroutine promise yet. - * This split is required because {@link executeSubagent} synchronously fires - * `onResponseChunk(startEvent)` when invoked — the chunk handler needs a - * `jobId` to buffer into BEFORE the detached promise exists. The caller must - * invoke {@link attachBackgroundAgentPromise} immediately after launching the - * coroutine to wire the settle handlers that transition the status. + * Create the core registry record + live adapter view for ONE background + * agent job. Deliberately performs no capacity check: the caller owns the + * claim, so a whole batch can be claimed atomically + * ({@link allocateBackgroundAgentJobBatch}) instead of re-checking the shared + * registry once per job. */ -export function allocateBackgroundAgentJob(params: { +function createBackgroundAgentJobRecord(params: { agentType: string agentName: string - owner?: BackgroundAgentJob['owner'] + owner: BackgroundAgentJob['owner'] }): BackgroundAgentJob { - const owner = params.owner ?? { - clientSessionId: 'unknown-session', - rootRunId: 'unknown-root', - parentRunId: 'unknown-parent-run', - parentAgentId: 'unknown-parent-agent', - userInputId: 'unknown-input', - } - assertBackgroundAgentCapacity({ additional: 1, owner }) - const { agentType, agentName } = params + const { agentType, agentName, owner } = params // The unified core owns lifecycle/state: create in 'queued' with the // adapter's single `bg-agent-` id as the explicit job id, then immediately // transition to 'running' so the job is pollable the moment @@ -300,6 +362,52 @@ export function allocateBackgroundAgentJob(params: { return job } +/** + * Allocate a job id and a pending job record WITHOUT a coroutine promise yet. + * This split is required because {@link executeSubagent} synchronously fires + * `onResponseChunk(startEvent)` when invoked — the chunk handler needs a + * `jobId` to buffer into BEFORE the detached promise exists. The caller must + * invoke {@link attachBackgroundAgentPromise} immediately after launching the + * coroutine to wire the settle handlers that transition the status. + */ +export function allocateBackgroundAgentJob(params: { + agentType: string + agentName: string + owner?: BackgroundAgentJob['owner'] +}): BackgroundAgentJob { + const owner = resolveBackgroundAgentJobOwner(params.owner) + assertBackgroundAgentCapacity({ additional: 1, owner }) + return createBackgroundAgentJobRecord({ + agentType: params.agentType, + agentName: params.agentName, + owner, + }) +} + +/** + * Allocate a whole batch of background agent jobs under ONE capacity check, + * making the batch's claim on the shared registry atomic: a concurrent spawn + * can no longer land between a batch preflight and a per-job allocation and + * make a mid-batch allocation throw after earlier jobs of the same batch were + * already launched. Either every id in the batch exists, or the capacity error + * is thrown and no record/view was created at all. + * + * Same pre-allocate-then-attach contract as + * {@link allocateBackgroundAgentJob}: every returned job still needs + * {@link attachBackgroundAgentPromise} immediately after its coroutine is + * launched. + */ +export function allocateBackgroundAgentJobBatch(params: { + agents: Array<{ agentType: string; agentName: string }> + owner?: BackgroundAgentJob['owner'] +}): BackgroundAgentJob[] { + const owner = resolveBackgroundAgentJobOwner(params.owner) + assertBackgroundAgentCapacity({ additional: params.agents.length, owner }) + return params.agents.map(({ agentType, agentName }) => + createBackgroundAgentJobRecord({ agentType, agentName, owner }), + ) +} + /** Preflight a logical batch before the caller acquires leases or emits events. */ export function assertBackgroundAgentCapacity(params: { additional: number @@ -308,9 +416,9 @@ export function assertBackgroundAgentCapacity(params: { sweepBackgroundAgentJobs() if (params.additional <= 0) return const { owner } = params - const running = registry - .listRunning() - .filter((coreJob) => coreJob.state === 'running') + // Agent-kind only: shell `process` jobs share this registry but are bounded + // separately, so they must not consume the background-agent budget. + const running = listRunningAgentJobs() if (running.length + params.additional > MAX_RUNNING_BACKGROUND_AGENT_JOBS) { throw new Error( `Background agent concurrency limit reached (${MAX_RUNNING_BACKGROUND_AGENT_JOBS}). Join or cancel an existing job before spawning another.`, @@ -376,13 +484,23 @@ export function registerBackgroundAgentJob(params: { * unified core as lifecycle(completed) / lifecycle(error) events, then sync * the live view. Detached from registration so the caller doesn't need to * remember to wire `.then`/`.catch` at every registration site. + * + * The resolved value is passed THROUGH the lifecycle event as well as stamped + * on the view, so the core record owns the settled result and it outlives + * count-cap eviction of that view. A job the caller already cancelled keeps + * its cancellation receipt: the guard below returns before either stamp (and + * the core would reject the transition anyway, since terminal states absorb). */ function attachJobCompletionHandlers(job: BackgroundAgentJob): void { job.promise.then( (result) => { if (job.status === 'cancelled') return job.result = result - registry.emit(job.jobId, { type: 'lifecycle', state: 'completed' }) + registry.emit(job.jobId, { + type: 'lifecycle', + state: 'completed', + result, + }) const coreJob = registry.get(job.jobId) if (coreJob) syncViewFromCore(job, coreJob) }, @@ -462,6 +580,32 @@ export function getBackgroundAgentJob( return job } +/** + * Reconcile a parent's durable {@link AgentState.backgroundAgentJobs} intents + * against the live registry: an intent still marked 'running' whose job no + * longer exists is recorded as 'interrupted' with a terminal timestamp and + * reason. Intents are never dropped — only reconciled — so the parent keeps an + * auditable terminal record of detached work. + * + * Called at `loopAgentSteps` entry AND by the spawn preflight before it counts + * the background concurrency budget off these intents, so a job that vanished + * mid-turn stops consuming that budget instead of blocking later legitimate + * background spawns until the next turn. Idempotent: a reconciled intent is no + * longer 'running', so repeated calls within one turn are no-ops. + */ +export function reconcileInterruptedBackgroundAgentIntents( + state: AgentState, +): void { + for (const job of state.backgroundAgentJobs ?? []) { + if (job.status === 'running' && !getBackgroundAgentJob(job.jobId)) { + job.status = 'interrupted' + job.completedAt = Date.now() + job.error = + 'Background agent host process/session ended before a terminal receipt was recorded.' + } + } +} + export function listRunningBackgroundAgentJobs(owner?: { clientSessionId: string rootRunId: string @@ -469,9 +613,7 @@ export function listRunningBackgroundAgentJobs(owner?: { Pick > { sweepBackgroundAgentJobs() - const running = registry - .listRunning(owner) - .filter((coreJob) => coreJob.state === 'running') + const running = listRunningAgentJobs(owner) const result: Array< Pick > = [] @@ -530,6 +672,21 @@ export function readBackgroundAgentChunks(params: { const droppedChunks = Math.max(0, firstSequence - cursor - 1) const available = chunks.filter((chunk) => chunk.sequence > cursor) const nextCursor = available.at(-1)?.sequence ?? cursor + setConsumerCursor(job, consumerId, nextCursor) + return { chunks: available, nextCursor, droppedChunks } +} + +/** + * Record `consumerId`'s confirmed position in this job's stream, keeping the + * per-job cursor map bounded by {@link MAX_CONSUMER_CURSORS} (oldest insertion + * evicted first, never the consumer being written). Single writer for the + * cursor store so every path stays bounded the same way. + */ +function setConsumerCursor( + job: BackgroundAgentJob, + consumerId: string, + nextCursor: number, +): void { job.consumerCursors.set(consumerId, nextCursor) if (job.consumerCursors.size > MAX_CONSUMER_CURSORS) { const oldest = job.consumerCursors.keys().next().value @@ -537,7 +694,48 @@ export function readBackgroundAgentChunks(params: { job.consumerCursors.delete(oldest) } } - return { chunks: available, nextCursor, droppedChunks } +} + +/** + * This consumer's last confirmed position in the job's event stream, or + * undefined when it has never polled (or the settled view was count-cap + * evicted). check_background_agent uses it as the effective cursor for a poll + * that omits `cursor`, so such a poll returns only the events that consumer + * has not consumed instead of replaying the whole retained buffer. + * + * The stored number is whatever sequence space the consumer polls in — core + * EVENT sequences for check_background_agent, chunk-local sequences for + * {@link readBackgroundAgentChunks} — so one consumerId must stay on one API. + */ +export function getBackgroundAgentConsumerCursor( + jobId: string, + consumerId: string, +): number | undefined { + return views.get(jobId)?.consumerCursors.get(consumerId) +} + +/** + * Advance this consumer's stored position to the cursor the CORE confirmed. + * Monotonic and never past `confirmedCursor`: a follow-mode wait that timed out + * without new events confirms the cursor it started from, so the consumer keeps + * its place instead of skipping the events that arrive later. Bounded by + * {@link MAX_CONSUMER_CURSORS} like every other cursor write. + */ +export function advanceBackgroundAgentConsumerCursor( + jobId: string, + consumerId: string, + confirmedCursor: number, +): void { + const job = views.get(jobId) + if (!job || !Number.isFinite(confirmedCursor)) return + setConsumerCursor( + job, + consumerId, + Math.max( + job.consumerCursors.get(consumerId) ?? 0, + Math.floor(confirmedCursor), + ), + ) } export function backgroundAgentJobOwnedBy( @@ -556,19 +754,47 @@ export function takeDroppedBackgroundAgentChunkCount( return count } +/** + * The exact reason the adapter aborts a job cancelled through + * check_background_agent. Exported so the spawn handler can recognize a + * rejection driven by THIS job's own cancellation and record it as a + * cancellation instead of relabelling it as a failure. + */ +export const BACKGROUND_AGENT_CANCEL_REASON = + 'Cancelled by check_background_agent.' + +/** + * Outcome of a cancel request. `cancelled: false` is the IDEMPOTENT no-op case: + * the job is already settled (or its settled view was count-cap evicted), so + * there is nothing left to abort and the caller's poll can still report the + * job's state, events, and result. `errorMessage` is reserved for an id the + * unified core no longer knows at all. + */ +export type CancelBackgroundAgentJobResult = + | { cancelled: true; status: 'cancelled' } + | { cancelled: false; status: BackgroundAgentJobStatus } + | { errorMessage: string } + export function cancelBackgroundAgentJob( jobId: string, -): { cancelled: true; status: 'cancelled' } | { errorMessage: string } { +): CancelBackgroundAgentJobResult { + sweepBackgroundAgentJobs() + const coreJob = registry.get(jobId) + if (!coreJob) { + return { errorMessage: `No background agent job found with id "${jobId}".` } + } const job = views.get(jobId) if (!job) { - return { errorMessage: `No background agent job found with id "${jobId}".` } + // Only SETTLED views are ever evicted (the view owns the AbortController), + // so an id the core still knows without a view is a settled job. Reporting + // it as not_found would contradict the retention invariant, so it is + // reported as the idempotent no-op it is. + return { cancelled: false, status: jobStateToStatus(coreJob.state) } } if (job.status !== 'running') { - return { - errorMessage: `Background agent job "${jobId}" is already ${job.status}.`, - } + return { cancelled: false, status: job.status } } - const error = 'Cancelled by check_background_agent.' + const error = BACKGROUND_AGENT_CANCEL_REASON // The core folds lifecycle(cancelled) into its state machine (legal from // 'running', absorbing once terminal); the adapter performs the real abort. registry.cancel(jobId) @@ -579,14 +805,91 @@ export function cancelBackgroundAgentJob( return { cancelled: true, status: 'cancelled' } } +/** + * True when this job's OWN cancellation is what settled it: the adapter view or + * the core record already says 'cancelled', or this job's AbortController fired + * with {@link BACKGROUND_AGENT_CANCEL_REASON}. The spawn handler uses it to + * tell an explicit `check_background_agent({ cancel: true })` abort apart from + * an ordinary rejection (a subagent timeout, a parent-signal abort, a real + * error), so a cancelled job is not relabelled as a failure on the parent's + * durable intent and receipt while the registry keeps 'cancelled'. + * + * A parent-signal abort is deliberately NOT cancellation here: the combined + * signal the spawn handler builds never aborts this job's own controller, so + * only the adapter's cancel path can satisfy the reason check. + */ +export function backgroundAgentJobWasCancelled( + job: BackgroundAgentJob, +): boolean { + if (job.status === 'cancelled') return true + if (registry.get(job.jobId)?.state === 'cancelled') return true + const reason: unknown = job.abortController.signal.aborted + ? job.abortController.signal.reason + : undefined + const message = + reason instanceof Error + ? reason.message + : typeof reason === 'string' + ? reason + : undefined + return message === BACKGROUND_AGENT_CANCEL_REASON +} + +/** + * Terminally abandon a job that was allocated but whose coroutine was NEVER + * launched. Mirrors {@link cancelBackgroundAgentJob}'s idiom — fold a terminal + * lifecycle event into the core, then abort the adapter's controller — and + * additionally drops the view, because a pre-launch job still holds the + * allocation placeholder promise: no settle handler is wired until + * {@link attachBackgroundAgentPromise}, and nothing reaps background agent + * jobs, so without this the job stays 'running' forever and permanently + * consumes the process-wide (32) and per-root (8) background budget. + * + * Terminal (`error`) rather than `cancelled`: the spawn failed before the agent + * ran, and keeping `cancelled` to mean an explicit cancel is what lets + * {@link backgroundAgentJobWasCancelled} stay accurate. + * + * ONLY legal for a job whose coroutine was never launched — abandoning a live + * job would abort a running agent and drop the view that owns its + * AbortController. + */ +export function abandonPreLaunchBackgroundAgentJob( + job: BackgroundAgentJob, + reason: string, +): void { + const coreJob = registry.get(job.jobId) + // Terminal states absorb in the core, so an already-settled job is a no-op. + if (coreJob && !isTerminalJobState(coreJob.state)) { + registry.emit(job.jobId, { + type: 'lifecycle', + state: 'error', + error: reason, + }) + } + job.status = 'error' + job.completedAt = Date.now() + job.error = reason + if (!job.abortController.signal.aborted) { + job.abortController.abort(new Error(reason)) + } + views.delete(job.jobId) +} + /** * Registry-backed ownership check for check_background_agent. Returns the - * core's tri-state so the handler can distinguish not_found from foreign. + * core's tri-state unchanged so the handler can distinguish not_found from + * foreign. View presence is deliberately NOT consulted: the core owns both the + * lifecycle state and the settled `result`, so a job whose view was count-cap + * evicted is owned and still reports its result. Only a job the core no longer + * knows about (never allocated, or reclaimed by the settled-job TTL sweep) is + * not_found. The sweep runs first so this gate and the caller's subsequent + * reads observe the same registry state. */ export function assertBackgroundAgentJobOwned( jobId: string, owner: { clientSessionId: string; rootRunId: string }, ): AssertOwnedResult { + sweepBackgroundAgentJobs() return registry.assertOwned(jobId, owner) } @@ -605,8 +908,10 @@ export function snapshotBackgroundAgentJob( /** * Join/wait primitive over the unified core: resolve when a NEW agent_chunk * event (sequence > cursor) satisfies the predicate, or the job reaches a - * terminal state, or the timeout fires — driven purely off the registry's - * internal notifications (no sleep-polling). + * terminal state, or the timeout fires, or the caller's abort signal fires — + * driven purely off the registry's internal notifications (no sleep-polling). + * The core clamps the supplied cursor, so a cursor past the job's latest + * sequence cannot strand the waiter. */ export function waitForBackgroundAgentJob( jobId: string, diff --git a/sdk/src/__tests__/write-audit-findings.test.ts b/sdk/src/__tests__/write-audit-findings.test.ts index 6205cb10e4..17e7fb2087 100644 --- a/sdk/src/__tests__/write-audit-findings.test.ts +++ b/sdk/src/__tests__/write-audit-findings.test.ts @@ -14,6 +14,7 @@ import { snapshotCoverageCompletenessRule, writeAuditFindingsParams, } from '@codebuff/common/tools/params/tool/write-audit-findings' +import { containsStructuralAuditReceipt } from '@codebuff/common/util/audit-receipt' import { getContentHash } from '@codebuff/common/util/content-hash' import { @@ -67,6 +68,10 @@ describe('writeAuditFindings', () => { '## [HIGH] correctness — packages/agent-runtime/src/tools/tool-executor.ts:688', ) expect(markdown).toContain('### Files') + // The artifact records the snapshot its findings were evaluated against, so + // an already-exists collision can be checked against what is on disk rather + // than against the colliding caller's own snapshotId. + expect(markdown).toContain(`- Snapshot: ${input.snapshotId}`) // The declared domains must be visible to agents that parse the Markdown // artifact, matching structuralReceipt.domains in the JSON receipt. expect(markdown).toContain('### Domains') @@ -90,6 +95,15 @@ describe('writeAuditFindings', () => { expect(markdown).not.toContain('### Domains') }) + test('omits the snapshot attestation line when snapshotId is omitted', () => { + const { snapshotId: _snapshotId, ...legacyInput } = input + const markdown = renderAuditFindingsMarkdown(legacyInput) + + // A legacy artifact attests to no snapshot, so a collision against it can + // never claim snapshot-bound coverage. + expect(markdown).not.toContain('- Snapshot:') + }) + test('cannot forge a heading with a bare CR in a finding field', () => { const markdown = renderAuditFindingsMarkdown({ ...input, @@ -221,6 +235,22 @@ describe('writeAuditFindings', () => { expect(typeof collisionMessage).toBe('string') expect(collisionMessage).not.toBe('') expect(collisionMessage).toContain('the file already exists') + // Exclusive-create keeps one shard from clobbering another's findings, so a + // collision means THIS shard's findings are already persisted: the message + // must name the existing artifact path and must not direct a duplicate + // write under a suffixed shard id. + expect(collisionMessage).toContain(artifactPath) + expect(collisionMessage).toContain( + "this shard's findings are already persisted", + ) + expect(collisionMessage).toContain('do not write a duplicate') + expect(collisionMessage).not.toContain(`"${input.shardId}-2"`) + expect(collisionMessage).not.toContain('retry with a distinct shard id') + // A distinct shard id stays available, but only for a deliberately + // different artifact. + expect(collisionMessage).toContain( + 'Use a distinct shard id only when intentionally writing an additional, different artifact.', + ) expect(await fs.readFile(`/repo/${artifactPath}`, 'utf8')).toBe(markdown) }) @@ -1101,4 +1131,221 @@ describe('writeAuditFindings', () => { }) expect(JSON.stringify(value)).not.toContain(oversizedSlug) }) + + test('marks an already-existing artifact as durably persisted for its snapshot', async () => { + const fs = createMockFs() + const shardId = 'runtime-collision' + const params = { ...input, shardId } + const artifactPath = auditFindingsArtifactPath(params) + const markdown = renderAuditFindingsMarkdown(params) + + const first = await writeAuditFindings({ + parameters: params, + cwd: '/repo', + fs, + }) + expect(first.mutation?.outcome).toBe('applied') + + const second = await writeAuditFindings({ + parameters: params, + cwd: '/repo', + fs, + }) + const collision = + second.output[0]?.type === 'json' ? second.output[0].value : undefined + + // Still a rejection: nothing is applied a second time and the compact + // success receipt is not synthesized for a write that did not happen. + expect(second.mutation).toBeDefined() + expect(second.mutation?.outcome).not.toBe('applied') + expect(collision).not.toHaveProperty('structuralReceipt') + expect(collision).not.toHaveProperty('contentHash') + expect(await fs.readFile(`/repo/${artifactPath}`, 'utf8')).toBe(markdown) + + // The rejection now carries an explicit snapshot-bound durable-persistence + // marker, echoing only the schema-validated shard id and the derived path. + // Asserted with toEqual so a marker that also forged the + // subsystem/file/domain coverage this rejected call never persisted fails. + const marker = + collision && + typeof collision === 'object' && + 'alreadyPersisted' in collision + ? collision.alreadyPersisted + : undefined + expect(marker).toEqual({ + schema_version: 1, + shardId, + artifactPath, + snapshot_id: input.snapshotId, + }) + expect(collision).toMatchObject({ artifactPath }) + + // The one shared gate the generator and buildRuntimeAgentReceipt consume + // accepts it, and only for this snapshot. + expect(containsStructuralAuditReceipt(second.output, input.snapshotId)).toBe( + true, + ) + expect( + containsStructuralAuditReceipt(second.output, 'snapshot-other'), + ).toBe(false) + + // The added field must still validate at the tool boundary. + expect( + writeAuditFindingsParams.outputSchema.safeParse(second.output).success, + ).toBe(true) + + // The no-duplicate recovery stays on the message. + const collisionMessage = + collision && typeof collision === 'object' && 'errorMessage' in collision + ? collision.errorMessage + : undefined + expect(collisionMessage).toContain('the file already exists') + expect(collisionMessage).toContain( + "this shard's findings are already persisted", + ) + expect(collisionMessage).toContain('do not write a duplicate') + }) + + test('omits the snapshot binding from the marker when the caller sent no snapshotId', async () => { + const fs = createMockFs() + const { snapshotId: _snapshotId, ...legacyInput } = input + const params = { ...legacyInput, shardId: 'runtime-collision-unbound' } + const artifactPath = auditFindingsArtifactPath(params) + + await writeAuditFindings({ parameters: params, cwd: '/repo', fs }) + const { output: second } = await writeAuditFindings({ + parameters: params, + cwd: '/repo', + fs, + }) + const collision = second[0]?.type === 'json' ? second[0].value : undefined + + // No snapshot binding at all, so an unbound shard cannot satisfy a + // snapshot-bound gate. + expect(collision).toMatchObject({ artifactPath }) + expect( + collision && + typeof collision === 'object' && + 'alreadyPersisted' in collision + ? collision.alreadyPersisted + : undefined, + ).toEqual({ + schema_version: 1, + shardId: params.shardId, + artifactPath, + }) + expect(containsStructuralAuditReceipt(second, 'snapshot-1')).toBe(false) + expect(containsStructuralAuditReceipt(second)).toBe(false) + }) + + test('does not mark an ordinary rejected write as durably persisted', async () => { + const fs = createMockFs() + const shardId = 'runtime-invalid-parameters' + const artifactPath = auditFindingsArtifactPath({ + sessionSlug: input.sessionSlug, + shardId, + }) + + // Widening the gate to ANY rejection would let a failed write claim + // coverage, so only the already-exists case carries the marker. + const { output: result } = await writeAuditFindings({ + parameters: { ...input, shardId, findings: [], noIssuesFound: false }, + 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.', + }) + expect(value).not.toHaveProperty('alreadyPersisted') + expect(containsStructuralAuditReceipt(result, input.snapshotId)).toBe(false) + }) + + test('does not bind the collision marker to a snapshot the persisted artifact never attested to', async () => { + const fs = createMockFs() + const shardId = 'runtime-stale-snapshot' + const firstParams = { ...input, shardId } + const artifactPath = auditFindingsArtifactPath(firstParams) + const firstMarkdown = renderAuditFindingsMarkdown(firstParams) + + const first = await writeAuditFindings({ + parameters: firstParams, + cwd: '/repo', + fs, + }) + expect(first.mutation?.outcome).toBe('applied') + + // Same shard id, NEW snapshot: the artifact on disk was written for + // snapshot-1, so binding the marker to the caller's snapshotId would let + // this re-run satisfy a snapshot-2-bound coverage gate with a stale file. + const { output: second } = await writeAuditFindings({ + parameters: { ...firstParams, snapshotId: 'snapshot-2' }, + cwd: '/repo', + fs, + }) + const collision = second[0]?.type === 'json' ? second[0].value : undefined + + expect(collision).toMatchObject({ artifactPath }) + expect( + collision && + typeof collision === 'object' && + 'alreadyPersisted' in collision + ? collision.alreadyPersisted + : undefined, + ).toEqual({ schema_version: 1, shardId, artifactPath }) + // Unbound marker: neither the new snapshot nor the artifact's own snapshot + // can be claimed from this rejection. + expect(containsStructuralAuditReceipt(second, 'snapshot-2')).toBe(false) + expect(containsStructuralAuditReceipt(second, input.snapshotId)).toBe(false) + expect(containsStructuralAuditReceipt(second)).toBe(false) + + const collisionMessage = + collision && typeof collision === 'object' && 'errorMessage' in collision + ? collision.errorMessage + : undefined + expect(collisionMessage).toContain( + "does not attest to this call's snapshotId", + ) + expect(collisionMessage).not.toContain('do not write a duplicate') + // The persisted artifact is left exactly as the first call wrote it. + expect(await fs.readFile(`/repo/${artifactPath}`, 'utf8')).toBe( + firstMarkdown, + ) + }) + + test('leaves the collision marker unbound when the persisted artifact attests to no snapshot', async () => { + const shardId = 'runtime-legacy-artifact' + const params = { ...input, shardId } + const artifactPath = auditFindingsArtifactPath(params) + const { snapshotId: _snapshotId, ...legacyInput } = params + const legacyMarkdown = renderAuditFindingsMarkdown(legacyInput) + // A legacy artifact carries no `- Snapshot:` line, so a snapshot-bound + // re-run colliding with it must not be able to claim that snapshot's + // coverage from the collision. + const fs = createMockFs({ + files: { [`/repo/${artifactPath}`]: legacyMarkdown }, + }) + + const { output: result } = await writeAuditFindings({ + parameters: params, + cwd: '/repo', + fs, + }) + const collision = result[0]?.type === 'json' ? result[0].value : undefined + + expect( + collision && + typeof collision === 'object' && + 'alreadyPersisted' in collision + ? collision.alreadyPersisted + : undefined, + ).toEqual({ schema_version: 1, shardId, artifactPath }) + expect(containsStructuralAuditReceipt(result, input.snapshotId)).toBe(false) + expect(containsStructuralAuditReceipt(result)).toBe(false) + expect(await fs.readFile(`/repo/${artifactPath}`, 'utf8')).toBe( + legacyMarkdown, + ) + }) }) diff --git a/sdk/src/impl/model-provider.ts b/sdk/src/impl/model-provider.ts index fde6a9def6..61f74f307e 100644 --- a/sdk/src/impl/model-provider.ts +++ b/sdk/src/impl/model-provider.ts @@ -172,9 +172,7 @@ export function selectAdaptiveReasoningEffort(params: { ? 'high' : /editor|test-writer|general-agent|base2|base$/.test(id) ? 'medium' - : /file-picker|code-searcher|context-pruner|researcher|synthesizer/.test( - id, - ) + : /file-picker|context-pruner|researcher|synthesizer/.test(id) ? 'low' : 'medium' const efforts = params.efforts diff --git a/sdk/src/tools/write-audit-findings.ts b/sdk/src/tools/write-audit-findings.ts index 3bb24f76d9..4cdb38f948 100644 --- a/sdk/src/tools/write-audit-findings.ts +++ b/sdk/src/tools/write-audit-findings.ts @@ -4,6 +4,7 @@ import { isFileMutationResultV1 } from '@codebuff/common/tools/results/filesyste import { getContentHash } from '@codebuff/common/util/content-hash' import { changeFile, UNREPORTABLE_ECHO } from './change-file' +import { resolveFilePathForFileSystemReadOperation } from './path-utils' import type { CodebuffToolOutput } from '@codebuff/common/tools/list' import type { FileMutationResultV1 } from '@codebuff/common/tools/results/filesystem' @@ -47,6 +48,68 @@ function rawArtifactIdentifier(parameters: unknown, key: string): string { return parsed.success ? parsed.data : UNREPORTABLE_ECHO } +/** + * The artifact's own snapshot attestation line. Rendered into the artifact + * header for every snapshot-bound call, so the persisted file itself records + * which snapshot its findings were evaluated against. That persisted line is + * what {@link persistedSnapshotAttestation} reads back, so an already-exists + * collision can only ever claim the snapshot the file on disk actually names. + */ +function snapshotAttestationLine(snapshotId: string): string { + return `- Snapshot: ${singleLine(snapshotId)}` +} + +/** + * The snapshot id the PERSISTED artifact attests to, or undefined when it + * attests to none — a legacy artifact written before snapshot binding, or one + * written by a call that supplied no snapshotId. Parsed from the artifact's own + * {@link snapshotAttestationLine}, so binding a collision marker to the + * caller's snapshotId can never be satisfied by a stale or unattested file. + */ +function persistedSnapshotAttestation(persisted: string): string | undefined { + const prefix = '- Snapshot: ' + for (const line of persisted.split('\n')) { + if (line.startsWith(prefix)) { + return line.slice(prefix.length).trim() || undefined + } + } + return undefined +} + +/** + * The exact bytes of the artifact ALREADY ON DISK, or null when they cannot be + * read. + * + * Read once per collision because both decisions depend on the same bytes: + * whether the persisted artifact holds THIS call's findings (byte identity), + * and which snapshot that artifact attests to. Denies by default — an + * unreadable file (it may vanish between the rejected create and this read) or + * a path that does not resolve inside the project returns null, which keeps the + * collision a genuine conflict and the coverage gate closed. + */ +async function readPersistedArtifact(params: { + artifactPath: string + cwd: string + fs: CodebuffFileSystem +}): Promise { + const resolved = await resolveFilePathForFileSystemReadOperation( + params.cwd, + params.artifactPath, + params.fs, + ) + if (!resolved) return null + try { + const raw = await params.fs.readFile(resolved.operationPath) + return typeof raw === 'string' + ? raw + : new TextDecoder('utf-8').decode( + new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength), + ) + } catch { + return null + } +} + export function renderAuditFindingsMarkdown(input: AuditFindingsInput): string { const subsystemIds = input.coverage.subsystemIds.map(singleLine) const featureIds = input.coverage.featureIds.map(singleLine) @@ -57,6 +120,9 @@ export function renderAuditFindingsMarkdown(input: AuditFindingsInput): string { `- Subsystems: ${subsystemIds.join(', ') || '(none)'}`, `- Features: ${featureIds.join(', ') || '(none)'}`, `- Files covered: ${files.length}`, + // Persisted snapshot binding: an already-exists collision may only claim + // durable coverage for the snapshot the artifact on disk actually names. + ...(input.snapshotId ? [snapshotAttestationLine(input.snapshotId)] : []), '', ] if (input.noIssuesFound) { @@ -117,6 +183,51 @@ export function findFileMutationResult( return undefined } +/** + * The compact receipt a persisted artifact yields. Shared by the successful + * write and by an idempotent already-persisted collision, so a retried shard + * hands the parent the SAME snapshot-bound `structuralReceipt` + * evaluate_audit_coverage accepts instead of a rejection it can never compose. + */ +function auditFindingsReceipt(params: { + input: AuditFindingsInput + artifactPath: string + content: string +}) { + const { input, artifactPath, content } = params + const severityCounts = { + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + } + for (const finding of input.findings) severityCounts[finding.severity]++ + return { + 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), + } +} + export type WriteAuditFindingsResult = { output: CodebuffToolOutput<'write_audit_findings'> /** @@ -195,56 +306,96 @@ export async function writeAuditFindings(params: { const reported = mutation ? mutation.errors.map((error) => error.message).join('; ') : '' + const errorMessage = + reported || 'Audit findings artifact was not confirmed as written.' + // The artifact is created exclusively (`expectedHash: null`) so one shard + // can never clobber another's findings. A collision therefore means an + // artifact for this shard id is already persisted at the derived path — but + // only BYTE-IDENTICAL content proves that artifact holds THIS call's + // findings, which is the one case that may be reported as already done. + const alreadyExists = /already exists/i.test(reported) + // Read the persisted artifact ONCE: the content-identity decision and the + // snapshot the marker may bind to both come from those exact bytes. + const persisted = alreadyExists + ? await readPersistedArtifact({ + artifactPath, + cwd: params.cwd, + fs: params.fs, + }) + : null + // Content identity is the only proof that the persisted artifact holds THIS + // call's findings. Without it, a retry with different or expanded findings + // would be reported as already done while those findings are persisted + // nowhere. A file of a different length cannot be identical, so length + // rejects early; an unreadable file denies by default. + const persistedIsThisCall = + persisted !== null && + persisted.length === content.length && + persisted === content + // Bind the marker only to a snapshot the artifact ON DISK attests to, so + // neither a stale artifact from another snapshot nor a legacy artifact with + // no attestation can satisfy a snapshot-bound coverage gate. + const persistedSnapshotId = + persisted === null ? undefined : persistedSnapshotAttestation(persisted) + const boundSnapshotId = + persistedIsThisCall && + input.snapshotId && + persistedSnapshotId === input.snapshotId + ? input.snapshotId + : undefined + if (alreadyExists) { + // Still a rejection: this call wrote nothing, so it never synthesizes the + // compact success receipt (no structuralReceipt, no contentHash). What it + // adds is the explicit already-persisted marker, and that marker credits + // the shared coverage gate ONLY when the persisted bytes are this call's + // findings AND attest to this call's snapshot — which is what keeps a + // retried shard from being permanently uncoverable without letting a + // stale artifact stand in for findings stored nowhere. Only the + // schema-validated shard id and the runtime-derived path are echoed. + const recovery = persistedIsThisCall + ? ` Shard id "${input.shardId}": this shard's findings are already persisted at ${artifactPath} and the artifact on disk is byte-identical to this call, so treat this as already written and do not write a duplicate. Use a distinct shard id only when intentionally writing an additional, different artifact.` + : ` Shard id "${input.shardId}": an artifact already exists at ${artifactPath}, but its contents are not this call's findings${ + input.snapshotId && persistedSnapshotId !== input.snapshotId + ? " and it does not attest to this call's snapshotId" + : '' + }, so it cannot stand in for them and nothing from this call is persisted. Persist these findings under a distinct shard id to obtain a composable coverage receipt.` + return { + output: [ + { + type: 'json', + value: { + artifactPath, + errorMessage: `${errorMessage}${recovery}`, + alreadyPersisted: { + schema_version: 1 as const, + shardId: input.shardId, + artifactPath, + ...(boundSnapshotId ? { snapshot_id: boundSnapshotId } : {}), + }, + }, + }, + ], + ...(mutation ? { mutation } : {}), + } + } + // Any other rejection carries no durable-persistence marker at all: + // widening the gate to every failure would let a write that never landed + // claim coverage. return { output: [ { type: 'json', - value: { - artifactPath, - errorMessage: - reported || - 'Audit findings artifact was not confirmed as written.', - }, + value: { artifactPath, errorMessage }, }, ], ...(mutation ? { mutation } : {}), } } - const severityCounts = { - CRITICAL: 0, - HIGH: 0, - MEDIUM: 0, - LOW: 0, - } - for (const finding of input.findings) severityCounts[finding.severity]++ 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), - }, + value: auditFindingsReceipt({ input, artifactPath, content }), }, ], mutation,