diff --git a/.agents/types/tools.ts b/.agents/types/tools.ts index e8ec0e801c..1a1590bfa3 100644 --- a/.agents/types/tools.ts +++ b/.agents/types/tools.ts @@ -251,6 +251,7 @@ export interface EditTransactionParams { occurrenceIndex?: number /** Optional authenticated cap.v3 readCapability copied verbatim from the matching fresh read_files editAnchor. */ basedOnRead?: string + /** 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. */ skipIfMissing?: boolean }[] } @@ -1058,6 +1059,7 @@ export interface StrReplaceParams { occurrenceIndex?: number /** Optional authenticated cap.v3 readCapability copied verbatim from the matching fresh read_files editAnchor. */ basedOnRead?: string + /** 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. */ skipIfMissing?: boolean }[] } diff --git a/agents/__tests__/base2-progressive-tool-disclosure.test.ts b/agents/__tests__/base2-progressive-tool-disclosure.test.ts index d216ec9482..796c62b847 100644 --- a/agents/__tests__/base2-progressive-tool-disclosure.test.ts +++ b/agents/__tests__/base2-progressive-tool-disclosure.test.ts @@ -4,6 +4,7 @@ import { loopAgentSteps } from '@codebuff/agent-runtime/run-agent-step' import { getToolSet } from '@codebuff/agent-runtime/tools/prompts' import { getEffectiveAgentToolNames } from '@codebuff/agent-runtime/util/agent-tool-names' import { + ALLOW_ALL_TIER_TOOLS, BASE2_CORE_TOOL_NAMES, BASE2_TIER_TOOL_NAMES, filterByUnlockedTiers, @@ -18,23 +19,10 @@ import { userMessage, } from '@codebuff/common/util/messages' +import { createBase2 } from '../base2/base2' import { - createBase2, - getPublishUnlockedToolTiers, - getPublishUnlockedToolTiersWithCanary, -} from '../base2/base2' -import { - AUDIT_TOOLS, - CORE_TOOLS, - deriveIntentSignals, - IMPLEMENT_TOOLS, - isEnvFlagEnabled, - isProgressiveToolDisclosureEnvEnabled, - JOB_EXTRA_TOOLS, - MEDIA_3D_TOOLS, resolveModelToolNames, - resolveUnlockedTiersForPhase, - type ToolTier, + type UnlockedToolTier, } from '../base2/tool-tiers' import type { AgentTemplate } from '@codebuff/agent-runtime/templates/types' @@ -53,47 +41,6 @@ const PROGRAMMATIC_TOOL_NAMES = [ 'get_build_targets', ] as const -const CORE_ALWAYS = [ - 'spawn_agents', - 'query_index', - 'read_files', - 'read_outline', - 'read_subtree', - 'list_directory', - 'glob', - 'code_search', - 'skill', - 'suggest_followups', - 'list_jobs', - 'check_job', - 'check_background_agent', - 'read_logs', -] as const - -const IMPLEMENT_SAMPLE = [ - 'edit_transaction', - 'create_plan', - 'update_plan_status', - 'inspect_workspace', - 'inspect_environment', - 'get_affected_tests', - 'get_build_targets', -] as const - -const AUDIT_SAMPLE = [ - 'inspect_codebase_structure', - 'inspect_feature_completeness', - 'evaluate_audit_coverage', - 'get_change_review_bundle', - 'get_task', -] as const - -const MEDIA_SAMPLE = [ - 'read_image', - 'inspect_3d_asset', - 'render_3d_preview', -] as const - function buildRepresentativeSkills(count: number): SkillsMap { return Object.fromEntries( Array.from({ length: count }, (_, index) => { @@ -130,100 +77,177 @@ async function toolSurfaceTokenCount(toolNames: string[]): Promise { } describe('base2 progressive tool disclosure (M1)', () => { - test('flag default on / omit option equals explicit true core-only surface', () => { - const implicit = createBase2('default') - const explicitOn = createBase2('default', { - progressiveToolDisclosure: true, - }) - const explicitOff = createBase2('default', { - progressiveToolDisclosure: false, - }) - // Default flipped ON: implicit is core-only, explicit false is full surface - expect(implicit.toolNames).toEqual(explicitOn.toolNames) - expect(implicit.toolNames).not.toEqual(explicitOff.toolNames) - expect(implicit.toolNames).toEqual( - resolveModelToolNames({ - mode: 'default', - progressiveToolDisclosure: true, - }), - ) - }) - - test('flag off explicit: full surface contains implement/audit/media/job tools', () => { - const tools = createBase2('default', { - progressiveToolDisclosure: false, - }).toolNames ?? [] - expect(tools).toContain('edit_transaction') - expect(tools).toContain('inspect_codebase_structure') - expect(tools).toContain('inspect_feature_completeness') - expect(tools).toContain('kill_job') - expect(tools).toContain('read_image') - expect(tools).toContain('edit_3d_asset') - expect(tools).toContain('create_plan') - expect(tools).toContain('run_targeted_validation') - expect(tools).toContain('code_search') - }) - - test('CORE_TOOLS includes root content-search tool', () => { - expect(CORE_TOOLS).toContain('code_search') + test('the default surface is the full mode-resolved surface', () => { + const agent = createBase2('default') + // Explicit expected surface rather than a re-derivation via + // resolveModelToolNames (createBase2 calls that same function with these + // same defaults, so comparing against it could never fail). CORE order + // first, then implement/audit/media_3d/job_extra in canonical tier order, + // minus run_terminal_command (execute-plan only, and default mode is not + // executePlan). Any change to CORE, the tier map, or the mode gates must + // fail loudly here. + expect(agent.toolNames).toEqual([ + 'spawn_agents', + 'query_index', + 'read_files', + 'read_outline', + 'read_subtree', + 'list_directory', + 'glob', + 'code_search', + 'ask_user', + 'skill', + 'suggest_followups', + 'write_todos', + 'list_jobs', + 'check_job', + 'check_background_agent', + 'read_logs', + 'edit_transaction', + 'create_plan', + 'update_plan_status', + 'inspect_workspace', + 'inspect_environment', + 'get_affected_tests', + 'get_build_targets', + 'run_targeted_validation', + 'inspect_codebase_structure', + 'inspect_feature_completeness', + 'evaluate_audit_coverage', + 'get_change_review_bundle', + 'get_task', + 'read_image', + 'inspect_3d_asset', + 'render_3d_preview', + 'edit_3d_asset', + 'kill_job', + ]) }) - test('flag on core-only: CORE present; IMPLEMENT/AUDIT/MEDIA/JOB_EXTRA absent', () => { - const tools = createBase2('default', { - progressiveToolDisclosure: true, - }).toolNames ?? [] - - for (const name of CORE_ALWAYS) { - expect(tools).toContain(name) + test('mode gates apply across every gate combination', () => { + // Exercise every mode-gate combination: each surface stays within the + // derived CORE + tier set, and the mode-gated tools appear exactly when + // their gate allows them. + const derived = new Set([ + ...BASE2_CORE_TOOL_NAMES, + ...Object.values(BASE2_TIER_TOOL_NAMES).flat(), + ]) + for (const planOnly of [false, true]) { + for (const executePlan of [false, true]) { + for (const noAskUser of [false, true]) { + for (const mode of ['default', 'fast'] as const) { + const label = `${mode} planOnly=${planOnly} executePlan=${executePlan} noAskUser=${noAskUser}` + const surface = resolveModelToolNames({ + mode, + planOnly, + executePlan, + noAskUser, + }) + const names = new Set(surface) + expect(surface.length, label).toBe(names.size) + for (const name of surface) { + expect(derived.has(name), `${label}:${name}`).toBe(true) + } + expect(names.has('ask_user'), label).toBe(!noAskUser) + expect(names.has('write_todos'), label).toBe( + mode !== 'fast' && !planOnly, + ) + expect(names.has('edit_transaction'), label).toBe(!planOnly) + expect(names.has('run_targeted_validation'), label).toBe(!planOnly) + expect(names.has('run_terminal_command'), label).toBe( + !planOnly && executePlan, + ) + } + } + } } - expect(tools).toContain('ask_user') - expect(tools).toContain('write_todos') + }) - for (const name of IMPLEMENT_SAMPLE) { - expect(tools).not.toContain(name) - } - expect(tools).not.toContain('run_targeted_validation') - expect(tools).not.toContain('run_terminal_command') - for (const name of AUDIT_SAMPLE) { - expect(tools).not.toContain(name) - } - for (const name of MEDIA_SAMPLE) { - expect(tools).not.toContain(name) - } - expect(tools).not.toContain('edit_3d_asset') - expect(tools).not.toContain('kill_job') + test('createBase2 unlockedTiers passthrough narrows the shipped surface', () => { + // `unlockedTiers` is the only control that narrows what createBase2 ships. + const coreOnly = createBase2('default', { unlockedTiers: [] }) + // Explicit expected CORE-only surface (same reason as above: comparing + // against resolveModelToolNames with the identical arguments createBase2 + // already used cannot fail). Default mode keeps both mode-gated CORE + // tools (ask_user, write_todos). + expect(coreOnly.toolNames).toEqual([ + 'spawn_agents', + 'query_index', + 'read_files', + 'read_outline', + 'read_subtree', + 'list_directory', + 'glob', + 'code_search', + 'ask_user', + 'skill', + 'suggest_followups', + 'write_todos', + 'list_jobs', + 'check_job', + 'check_background_agent', + 'read_logs', + ]) + // The dormant runtime ceiling is deliberately NOT narrowed with it: it is + // the default (all non-core tiers) mode-resolved surface, so flipping + // progressiveToolDisclosure on could still unlock a tier instead of being + // stuck CORE-only. The caller's narrowing lives in toolNames above, and + // progressiveToolDisclosure: false keeps the ceiling unused today. + expect( + coreOnly.programmaticConfig?.fullToolSurface as string[] | undefined, + ).toContain('edit_transaction') + + const implementOnly = + createBase2('default', { unlockedTiers: ['implement'] }).toolNames ?? [] + expect(implementOnly).toContain('edit_transaction') + expect(implementOnly).not.toContain('read_image') + expect(implementOnly).not.toContain('kill_job') }) - test('flag on + all unlocked tiers exposes gated tools', () => { - const unlockedTiers: ToolTier[] = [ - 'implement', - 'audit', - 'media_3d', - 'job_extra', + test('fullToolSurface publishes the default mode-resolved ceiling, not the narrowed surface', () => { + // The runtime ceiling is only ever membership-tested (agent-tool-names.ts + // builds a Set from it), and it is dormant while + // progressiveToolDisclosure is pinned false. It is derived from the + // DEFAULT (all non-core tiers) mode-resolved surface — the same list the + // identical mode options WITHOUT `unlockedTiers` ship as toolNames — so a + // caller-narrowed surface cannot leave behind a ceiling that can never + // unlock a tier. It is also its own array, so an in-place mutation by + // either consumer cannot silently move the other. + const cases: Array<{ + label: string + options?: Parameters[1] + // Same mode gates, no caller narrowing: its toolNames IS the ceiling. + ceilingOptions?: Parameters[1] + }> = [ + { label: 'defaults' }, + { label: 'core-only narrowing', options: { unlockedTiers: [] } }, + { + label: 'implement-only narrowing', + options: { unlockedTiers: ['implement'] }, + }, + { + label: 'plan-only', + options: { planOnly: true }, + ceilingOptions: { planOnly: true }, + }, + { + label: 'execute-plan', + options: { executePlan: true }, + ceilingOptions: { executePlan: true }, + }, ] - const tools = resolveModelToolNames({ - mode: 'default', - executePlan: true, - progressiveToolDisclosure: true, - unlockedTiers, - }) - - expect(tools).toContain('edit_transaction') - expect(tools).toContain('create_plan') - expect(tools).toContain('run_targeted_validation') - expect(tools).toContain('run_terminal_command') - expect(tools).toContain('inspect_codebase_structure') - expect(tools).toContain('inspect_feature_completeness') - expect(tools).toContain('read_image') - expect(tools).toContain('edit_3d_asset') - expect(tools).toContain('kill_job') + for (const { label, options, ceilingOptions } of cases) { + const agent = createBase2('default', options) + const ceiling = agent.programmaticConfig?.fullToolSurface + const expectedCeiling = createBase2('default', ceilingOptions).toolNames + expect(ceiling, label).toEqual(expectedCeiling) + expect(ceiling, label).not.toBe(expectedCeiling) + expect(ceiling, label).not.toBe(agent.toolNames) + } }) - test('planOnly + progressive off: still no edit_transaction', () => { - const tools = createBase2('default', { - planOnly: true, - progressiveToolDisclosure: false, - }).toolNames ?? [] + test('planOnly withholds the mutation/execution tools', () => { + const tools = createBase2('default', { planOnly: true }).toolNames ?? [] expect(tools).not.toContain('edit_transaction') expect(tools).not.toContain('edit_3d_asset') expect(tools).not.toContain('run_terminal_command') @@ -231,11 +255,10 @@ describe('base2 progressive tool disclosure (M1)', () => { expect(tools).not.toContain('run_targeted_validation') }) - test('planOnly + progressive on + unlock implement: still no edit_transaction', () => { + test('planOnly + unlock implement: still no edit_transaction', () => { const tools = resolveModelToolNames({ mode: 'default', planOnly: true, - progressiveToolDisclosure: true, unlockedTiers: ['implement'], }) expect(tools).toContain('create_plan') @@ -246,281 +269,29 @@ describe('base2 progressive tool disclosure (M1)', () => { expect(tools).not.toContain('write_todos') }) - test('env canary on when option omitted enables progressive (core-only)', () => { - const previous = process.env.OPENBUFF_PROGRESSIVE_TOOL_DISCLOSURE - try { - process.env.OPENBUFF_PROGRESSIVE_TOOL_DISCLOSURE = 'true' - const tools = createBase2('default').toolNames ?? [] - expect(tools).toContain('spawn_agents') - expect(tools).not.toContain('edit_transaction') - expect(tools).not.toContain('kill_job') - expect(tools).not.toContain('read_image') - } finally { - if (previous === undefined) { - delete process.env.OPENBUFF_PROGRESSIVE_TOOL_DISCLOSURE - } else { - process.env.OPENBUFF_PROGRESSIVE_TOOL_DISCLOSURE = previous - } - } - }) - - test('explicit false overrides env canary on', () => { - const previous = process.env.OPENBUFF_PROGRESSIVE_TOOL_DISCLOSURE - try { - process.env.OPENBUFF_PROGRESSIVE_TOOL_DISCLOSURE = '1' - const tools = createBase2('default', { - progressiveToolDisclosure: false, - }).toolNames ?? [] - expect(tools).toContain('edit_transaction') - expect(tools).toContain('kill_job') - expect(tools).toContain('read_image') - } finally { - if (previous === undefined) { - delete process.env.OPENBUFF_PROGRESSIVE_TOOL_DISCLOSURE - } else { - process.env.OPENBUFF_PROGRESSIVE_TOOL_DISCLOSURE = previous - } - } - }) - - test('token budget: progressive on core-only tool surface is under 12k', async () => { - const tools = createBase2('default', { - progressiveToolDisclosure: true, - }).toolNames ?? [] - expect(await toolSurfaceTokenCount(tools)).toBeLessThan(12_000) + test('token budget: always-on full tool surface is under 22.5k', async () => { + // The deliberate new default is the full 34-tool surface (measured ~21.4k + // tokens). This is a regression ceiling for that decision, not a + // core-only budget. Kept tight (~5% headroom) so an accidental surface + // expansion trips it instead of growing silently. + const tools = createBase2('default').toolNames ?? [] + expect(await toolSurfaceTokenCount(tools)).toBeLessThan(22_500) }) test('programmaticToolNames unchanged vs today', () => { - const agent = createBase2('default') - expect(agent.programmaticToolNames).toEqual([...PROGRAMMATIC_TOOL_NAMES]) - const progressive = createBase2('default', { - progressiveToolDisclosure: true, - }) - expect(progressive.programmaticToolNames).toEqual([ - ...PROGRAMMATIC_TOOL_NAMES, - ]) - const explicitOff = createBase2('default', { - progressiveToolDisclosure: false, - }) - expect(explicitOff.programmaticToolNames).toEqual([ - ...PROGRAMMATIC_TOOL_NAMES, - ]) + for (const options of [ + undefined, + { unlockedTiers: [] as UnlockedToolTier[] }, + { planOnly: true }, + ]) { + expect(createBase2('default', options).programmaticToolNames).toEqual([ + ...PROGRAMMATIC_TOOL_NAMES, + ]) + } }) }) describe('tier resolution helpers (M1-T3)', () => { - describe('resolveUnlockedTiersForPhase', () => { - test('returns [] when all intents are false', () => { - expect( - resolveUnlockedTiersForPhase({ - implementIntent: false, - auditIntent: false, - mediaIntent: false, - jobIntent: false, - }), - ).toEqual([]) - }) - - test("returns ['implement'] when implementIntent is true", () => { - expect( - resolveUnlockedTiersForPhase({ - implementIntent: true, - auditIntent: false, - mediaIntent: false, - jobIntent: false, - }), - ).toEqual(['implement']) - }) - - test("returns ['audit'] when auditIntent is true", () => { - expect( - resolveUnlockedTiersForPhase({ - implementIntent: false, - auditIntent: true, - mediaIntent: false, - jobIntent: false, - }), - ).toEqual(['audit']) - }) - - test("returns ['media_3d'] when mediaIntent is true", () => { - expect( - resolveUnlockedTiersForPhase({ - implementIntent: false, - auditIntent: false, - mediaIntent: true, - jobIntent: false, - }), - ).toEqual(['media_3d']) - }) - - test("returns ['job_extra'] when jobIntent is true", () => { - expect( - resolveUnlockedTiersForPhase({ - implementIntent: false, - auditIntent: false, - mediaIntent: false, - jobIntent: true, - }), - ).toEqual(['job_extra']) - }) - - test('returns all four tiers when all intents are true', () => { - expect( - resolveUnlockedTiersForPhase({ - implementIntent: true, - auditIntent: true, - mediaIntent: true, - jobIntent: true, - }), - ).toEqual(['implement', 'audit', 'media_3d', 'job_extra']) - }) - - test("never returns 'core' regardless of input", () => { - const tiers = resolveUnlockedTiersForPhase({ - implementIntent: true, - auditIntent: true, - mediaIntent: true, - jobIntent: true, - }) - expect(tiers).not.toContain('core') - }) - }) - - describe('deriveIntentSignals', () => { - const idleBase = { - phase: 'idle', - pendingGateFileCount: 0, - hasOpenReviewerBlockers: false, - } - - test('implementIntent is true for implement-gating phases', () => { - for (const phase of [ - 'awaiting_validation', - 'repair_loop', - 'awaiting_review', - 'blocked', - ]) { - expect( - deriveIntentSignals({ ...idleBase, phase }).implementIntent, - ).toBe(true) - } - }) - - test('implementIntent is true when pendingGateFileCount > 0 even in idle phase', () => { - expect( - deriveIntentSignals({ ...idleBase, pendingGateFileCount: 2 }) - .implementIntent, - ).toBe(true) - }) - - test('implementIntent is true when hasOpenReviewerBlockers even in idle phase', () => { - expect( - deriveIntentSignals({ ...idleBase, hasOpenReviewerBlockers: true }) - .implementIntent, - ).toBe(true) - }) - - test('implementIntent is true when the prompt contains implement keywords', () => { - for (const lastUserPrompt of [ - 'please implement this feature', - 'fix the failing test', - 'refactor the parser', - 'update the docs', - 'create a new endpoint', - 'add a retry loop', - ]) { - expect( - deriveIntentSignals({ ...idleBase, lastUserPrompt }).implementIntent, - ).toBe(true) - } - }) - - test('implementIntent is false when idle with no pending files, blockers, or implement keywords', () => { - expect( - deriveIntentSignals({ - ...idleBase, - lastUserPrompt: 'what does this function do?', - }).implementIntent, - ).toBe(false) - }) - - test('auditIntent is true when the prompt contains audit keywords', () => { - for (const lastUserPrompt of [ - 'run a full audit', - 'check the coverage', - 'verify completeness', - 'do a systematic pass', - ]) { - expect( - deriveIntentSignals({ ...idleBase, lastUserPrompt }).auditIntent, - ).toBe(true) - } - }) - - test('auditIntent is true when phase is awaiting_review', () => { - expect( - deriveIntentSignals({ ...idleBase, phase: 'awaiting_review' }) - .auditIntent, - ).toBe(true) - }) - - test('mediaIntent is true when the prompt contains media file extensions', () => { - for (const lastUserPrompt of [ - 'look at diagram.png', - 'check photo.jpg', - 'the logo.webp asset', - 'open scene.blend', - 'inspect model.obj', - 'view scene.gltf', - 'load mesh.glb', - ]) { - expect( - deriveIntentSignals({ ...idleBase, lastUserPrompt }).mediaIntent, - ).toBe(true) - } - }) - - test('mediaIntent is false for prompts without media extensions', () => { - expect( - deriveIntentSignals({ - ...idleBase, - lastUserPrompt: 'summarize the readme file', - }).mediaIntent, - ).toBe(false) - }) - - test('jobIntent is true when the prompt contains job-management phrasing', () => { - for (const lastUserPrompt of [ - 'run this as a background job', - 'kill the job', - 'start the dev server', - 'watch the build', - 'tail -f the output log', - 'check_job for readiness', - ]) { - expect( - deriveIntentSignals({ ...idleBase, lastUserPrompt }).jobIntent, - ).toBe(true) - } - }) - - test('jobIntent is false for bare kill/server/logs/watch/tail tokens', () => { - for (const lastUserPrompt of [ - 'explain this function', - 'please kill the zombie metaphor in the docs', - 'is the server still up', - 'check the logs', - 'tail the output', - 'watch carefully how this works', - ]) { - expect( - deriveIntentSignals({ ...idleBase, lastUserPrompt }).jobIntent, - ).toBe(false) - } - }) - }) - describe('getEffectiveAgentToolNames — unlockedToolTiers empty semantics', () => { const fullSurfaceTemplate = { id: 'tiered', @@ -607,8 +378,7 @@ describe('tier resolution helpers (M1-T3)', () => { test('canary-off ignores stale non-empty unlockedToolTiers (resume/canary-off contract)', () => { // Persisted unlocks from a prior canary-on run must NOT re-activate // progressive CORE+tiers filtering when the live template has - // progressiveToolDisclosure explicitly off — that would permanently - // shrink a full-surface template on resume. + // progressiveToolDisclosure explicitly off (would shrink the surface). const canaryOffTemplate = { ...fullSurfaceTemplate, programmaticConfig: { @@ -622,24 +392,93 @@ describe('tier resolution helpers (M1-T3)', () => { }), ).toEqual(['spawn_agents', 'read_files', 'edit_transaction', 'kill_job']) }) + + test('a progressive template omitting fullToolSurface fails closed (no tier tool is appended)', () => { + // Fail-open-by-omission guard: without a published ceiling there is no + // mode gate to preserve, so no unlocked tier tool may be appended. + // Allow-all must be requested explicitly (see the next test). + const noCeilingTemplate = { + ...fullSurfaceTemplate, + toolNames: ['spawn_agents', 'read_files'], + programmaticConfig: {}, + } as AgentTemplate + const result = getEffectiveAgentToolNames(noCeilingTemplate, { + unlockedToolTiers: ['implement'], + }) + expect(result).toEqual(['spawn_agents', 'read_files']) + expect(result).not.toContain('edit_transaction') + expect(result).not.toContain('run_terminal_command') + }) + + test('fullToolSurface: ALLOW_ALL_TIER_TOOLS opts into appending every unlocked tier tool', () => { + const allowAllTemplate = { + ...fullSurfaceTemplate, + toolNames: ['spawn_agents', 'read_files'], + programmaticConfig: { fullToolSurface: ALLOW_ALL_TIER_TOOLS }, + } as AgentTemplate + const result = getEffectiveAgentToolNames(allowAllTemplate, { + unlockedToolTiers: ['implement'], + }) + expect(result).toContain('edit_transaction') + expect(result).toContain('run_terminal_command') + }) }) describe('filterByUnlockedTiers', () => { test('empty unlockedTiers returns CORE-only tools from the input list', () => { // Low-level helper: empty tiers mean CORE-only of the *input* list. // getEffectiveAgentToolNames deliberately does NOT call this for - // absent/empty agentState.unlockedToolTiers (persisted empty = template surface). + // absent/empty agentState.unlockedToolTiers (persisted empty = template + // surface); see packages/agent-runtime/src/util/base2-tool-tiers.ts. const result = filterByUnlockedTiers( ['spawn_agents', 'read_files', 'edit_transaction', 'kill_job'], [], + // The ceiling is a required parameter; these input lists carry no mode + // gates, so the tests opt into allow-all explicitly. + () => true, ) expect(result).toEqual(['spawn_agents', 'read_files']) }) + test('ALLOW_ALL_TIER_TOOLS admits every unlocked tier tool (explicit allow-all opt-out)', () => { + // Pins the explicit-sentinel branch. Allow-all is only ever reachable by + // passing ALLOW_ALL_TIER_TOOLS: a caller with no ceiling to pass (e.g. a + // progressive template omitting programmaticConfig.fullToolSurface) must + // fail closed instead of unlocking run_terminal_command by omission. + const result = filterByUnlockedTiers( + ['spawn_agents', 'read_files'], + ['implement'], + ALLOW_ALL_TIER_TOOLS, + ) + expect(result).toEqual([ + 'spawn_agents', + 'read_files', + 'edit_transaction', + 'create_plan', + 'update_plan_status', + 'inspect_workspace', + 'inspect_environment', + 'get_affected_tests', + 'get_build_targets', + 'run_targeted_validation', + 'run_terminal_command', + ]) + // Same result as an explicit allow-all predicate: the sentinel does not + // narrow. + expect(result).toEqual( + filterByUnlockedTiers( + ['spawn_agents', 'read_files'], + ['implement'], + () => true, + ), + ) + }) + test("unlockedTiers ['implement'] keeps CORE tools plus implement tools", () => { const result = filterByUnlockedTiers( ['spawn_agents', 'read_files'], ['implement'], + () => true, ) expect(result).toEqual([ 'spawn_agents', @@ -660,6 +499,7 @@ describe('tier resolution helpers (M1-T3)', () => { const result = filterByUnlockedTiers( ['spawn_agents', 'read_files'], ['implement', 'audit'], + () => true, ) expect(result).toEqual([ 'spawn_agents', @@ -687,6 +527,7 @@ describe('tier resolution helpers (M1-T3)', () => { const result = filterByUnlockedTiers( ['spawn_agents', 'read_files', 'edit_transaction'], ['media_3d', 'job_extra'], + () => true, ) expect(result).toEqual([ 'spawn_agents', @@ -703,6 +544,7 @@ describe('tier resolution helpers (M1-T3)', () => { const result = filterByUnlockedTiers( ['spawn_agents', 'read_files'], ['implement', 'audit', 'media_3d', 'job_extra'], + () => true, ) expect(result).toEqual([ 'spawn_agents', @@ -745,6 +587,7 @@ describe('tier resolution helpers (M1-T3)', () => { const result = filterByUnlockedTiers( ['create_plan', 'read_files', 'edit_transaction'], ['implement'], + () => true, ) expect(result).toEqual([ 'create_plan', @@ -764,51 +607,91 @@ describe('tier resolution helpers (M1-T3)', () => { const result = filterByUnlockedTiers( ['spawn_agents', 'edit_transaction'], ['implement'], + () => true, ) const occurrences = result.filter((name) => name === 'edit_transaction') expect(occurrences).toHaveLength(1) }) - }) -}) -// RF-2 sync guard: the tier membership is duplicated across -// agents/base2/tool-tiers.ts (CORE/IMPLEMENT/AUDIT/MEDIA_3D/JOB_EXTRA) and -// packages/agent-runtime/src/util/base2-tool-tiers.ts -// (BASE2_CORE_TOOL_NAMES/BASE2_TIER_TOOL_NAMES). agent-runtime cannot import -// from agents/ (wrong dependency direction), so the two lists are kept in sync -// only by a prose comment. These assertions make a one-sided edit fail loudly -// instead of silently narrowing the runtime tool surface. -describe('base2 tier membership — runtime mirror stays in sync', () => { - test('BASE2_CORE_TOOL_NAMES equals CORE_TOOLS', () => { - // CORE_TOOLS re-exports BASE2_CORE_TOOL_NAMES by construction, so this is - // intentionally vacuous — it cannot catch a drift. The real progressive - // core-only surface lives in the hand-encoded CORE buildArray inside - // resolveModelToolNames; the tests below tie THAT copy (and the other - // mode-gated modes) to the runtime constant so a one-sided edit fails. - expect([...BASE2_CORE_TOOL_NAMES]).toEqual([...CORE_TOOLS]) + test('sanitizes persisted unlockedTiers: non-string, core, unknown, dupes', () => { + // unlockedTiers carries persisted AgentState.unlockedToolTiers, so it is + // untrusted: non-string entries, the unconditional 'core' pseudo-tier, + // unknown tier names, and repeated entries must all be ignored without + // widening or duplicating the surface. + const result = filterByUnlockedTiers( + ['spawn_agents', 'read_files'], + [null, 42, 'core', 'nope', 'implement', 'implement'], + () => true, + ) + expect(result).toEqual([ + 'spawn_agents', + 'read_files', + 'edit_transaction', + 'create_plan', + 'update_plan_status', + 'inspect_workspace', + 'inspect_environment', + 'get_affected_tests', + 'get_build_targets', + 'run_targeted_validation', + 'run_terminal_command', + ]) + // The bogus 'nope' tier contributed nothing, and the duplicated + // 'implement' entry unlocked its tools exactly once. + expect(new Set(result).size).toBe(result.length) + }) + + test('templateAllows gates only the append path, never the keep path', () => { + // Documented asymmetry: a tier tool the template already lists is KEPT + // even when the mode ceiling rejects it (the static toolNames list is + // already mode-resolved), while the same tool is never APPENDED. + const rejectsEdits = (name: string) => name !== 'edit_transaction' + const kept = filterByUnlockedTiers( + ['spawn_agents', 'edit_transaction'], + ['implement'], + rejectsEdits, + ) + expect(kept).toContain('edit_transaction') + expect(kept.indexOf('edit_transaction')).toBe(1) + + const notAppended = filterByUnlockedTiers( + ['spawn_agents'], + ['implement'], + rejectsEdits, + ) + expect(notAppended).not.toContain('edit_transaction') + }) }) +}) +// Tier membership needs no list-equality tests: agents/base2/tool-tiers.ts +// CONSUMES the runtime lists directly (BASE2_CORE_TOOL_NAMES / +// BASE2_TIER_TOOL_NAMES from +// packages/agent-runtime/src/util/base2-tool-tiers.ts), and +// resolveModelToolNames DERIVES its surface from those constants, so a tier +// added there flows into the surfaced set automatically. These tests pin the +// derivation itself. +describe('base2 tier membership — resolveModelToolNames stays in sync', () => { test('progressive core-only surface matches BASE2_CORE_TOOL_NAMES exactly', () => { - // resolveModelToolNames' progressive CORE buildArray is a SECOND copy of - // CORE membership that no test previously exercised. In the default mode - // (ask_user + write_todos both allowed) the surfaced set must equal the - // runtime constant byte-for-byte, so a tool added/removed on either side - // fails loudly instead of silently narrowing/widening the tool surface. + // In the default mode (ask_user + write_todos both allowed) the CORE-only + // surface must equal the runtime constant, so a tool added or removed on + // either side fails loudly instead of silently changing the surface. const coreOnly = resolveModelToolNames({ mode: 'default', - progressiveToolDisclosure: true, unlockedTiers: [], }) // Bidirectional membership over string sets — avoids the ToolName[] sort() // widening that would break the AllToolNames[] toEqual overload, while still - // making a one-sided edit to either list fail loudly. + // making a one-sided edit to either list fail loudly. Each direction is + // checked against the OTHER list's set so neither loop is vacuous. const coreSet = new Set(BASE2_CORE_TOOL_NAMES) + const surfacedSet = new Set(coreOnly) expect(coreOnly.length).toBe(coreSet.size) for (const name of coreOnly) { expect(coreSet.has(name)).toBe(true) } for (const name of BASE2_CORE_TOOL_NAMES) { - expect(coreSet.has(name)).toBe(true) + expect(surfacedSet.has(name)).toBe(true) } }) @@ -819,7 +702,6 @@ describe('base2 tier membership — runtime mirror stays in sync', () => { const gated = resolveModelToolNames({ mode: 'fast', noAskUser: true, - progressiveToolDisclosure: true, unlockedTiers: [], }) const coreSet = new Set(BASE2_CORE_TOOL_NAMES) @@ -830,219 +712,70 @@ describe('base2 tier membership — runtime mirror stays in sync', () => { expect(gated).not.toContain('write_todos') }) - test('BASE2_TIER_TOOL_NAMES.implement equals IMPLEMENT_TOOLS', () => { - expect([...BASE2_TIER_TOOL_NAMES.implement]).toEqual([...IMPLEMENT_TOOLS]) - }) - - test('BASE2_TIER_TOOL_NAMES.audit equals AUDIT_TOOLS', () => { - expect([...BASE2_TIER_TOOL_NAMES.audit]).toEqual([...AUDIT_TOOLS]) - }) - - test('BASE2_TIER_TOOL_NAMES.media_3d equals MEDIA_3D_TOOLS', () => { - expect([...BASE2_TIER_TOOL_NAMES.media_3d]).toEqual([...MEDIA_3D_TOOLS]) - }) - - test('BASE2_TIER_TOOL_NAMES.job_extra equals JOB_EXTRA_TOOLS', () => { - expect([...BASE2_TIER_TOOL_NAMES.job_extra]).toEqual([...JOB_EXTRA_TOOLS]) - }) - - test('BASE2_TIER_TOOL_NAMES covers exactly the four non-core tiers', () => { - expect(Object.keys(BASE2_TIER_TOOL_NAMES).sort()).toEqual([ - 'audit', - 'implement', - 'job_extra', - 'media_3d', - ]) - }) -}) - -// RF-3 budget sync guard MOVED: the SEMANTIC_* / MODEL_CONTEXT_* mirror between -// packages/agent-runtime/src/util/context-pruning.ts and the serialized -// handleSteps block in agents/context-pruner.ts is no longer hand-copied, so the -// numeric-literal drift comparison that used to live here is obsolete. The -// inline block is generated by scripts/generate-pruner-budgets.ts and enforced -// structurally (stale-region + canonical-value parity) by -// agents/__tests__/pruner-budgets-freshness.test.ts. - -// RF-3/RF-4 sync guard: the exported pure helper `getPublishUnlockedToolTiers` -// must stay in sync with the serialized `publishUnlockedToolTiers` inline copy -// inside base2's handleSteps (which is inlined via .toString() + new Function). -// Previously this test readFileSync + Bun.Transpiler + new Function'd the -// inline source — brittle to formatting. Now it imports the pure helper directly -// and keeps the serialized-copy drift check as a lightweight behavioral guard. -describe('publishUnlockedToolTiers — inline copy matches canonical helpers', () => { - - const MATRIX: Array<{ - phase: string - pendingGateFileCount: number - hasOpenReviewerBlockers: boolean - lastUserPrompt?: string - }> = [ - // Idle with no signals. - { - phase: 'idle', - pendingGateFileCount: 0, - hasOpenReviewerBlockers: false, - lastUserPrompt: 'what does this function do?', - }, - // Idle with no prompt at all. - { - phase: 'idle', - pendingGateFileCount: 0, - hasOpenReviewerBlockers: false, - }, - // Implement-gating phases. - { - phase: 'awaiting_validation', - pendingGateFileCount: 0, - hasOpenReviewerBlockers: false, - lastUserPrompt: '', - }, - { - phase: 'repair_loop', - pendingGateFileCount: 0, - hasOpenReviewerBlockers: false, - lastUserPrompt: '', - }, - // awaiting_review triggers both implement and audit intent. - { - phase: 'awaiting_review', - pendingGateFileCount: 0, - hasOpenReviewerBlockers: false, - lastUserPrompt: '', - }, - { - phase: 'blocked', - pendingGateFileCount: 0, - hasOpenReviewerBlockers: false, - lastUserPrompt: '', - }, - // Pending gate files force implement intent even in idle phase. - { - phase: 'idle', - pendingGateFileCount: 3, - hasOpenReviewerBlockers: false, - lastUserPrompt: '', - }, - // Open reviewer blockers force implement intent. - { - phase: 'idle', - pendingGateFileCount: 0, - hasOpenReviewerBlockers: true, - lastUserPrompt: '', - }, - // Implement keyword in the prompt. - { - phase: 'idle', - pendingGateFileCount: 0, - hasOpenReviewerBlockers: false, - lastUserPrompt: 'please implement and fix this feature', - }, - // Audit keyword in the prompt. - { - phase: 'idle', - pendingGateFileCount: 0, - hasOpenReviewerBlockers: false, - lastUserPrompt: 'run a full audit of the coverage', - }, - // Media path in the prompt. - { - phase: 'idle', - pendingGateFileCount: 0, - hasOpenReviewerBlockers: false, - lastUserPrompt: 'look at diagram.png and scene.gltf', - }, - // Job-management phrasing in the prompt. - { - phase: 'idle', - pendingGateFileCount: 0, - hasOpenReviewerBlockers: false, - lastUserPrompt: 'kill the job and tail -f the logs', - }, - // Combined signals: implement phase + audit/media/job prompt keywords. - { - phase: 'awaiting_review', - pendingGateFileCount: 2, - hasOpenReviewerBlockers: true, - lastUserPrompt: 'audit the coverage, check logo.webp, and kill the job', - }, - ] - - test('pure helper mirrors deriveIntentSignals+resolveUnlockedTiersForPhase across the matrix (no file read / transpiler)', () => { - for (const input of MATRIX) { - const expectedSignals = deriveIntentSignals({ - phase: input.phase, - pendingGateFileCount: input.pendingGateFileCount, - hasOpenReviewerBlockers: input.hasOpenReviewerBlockers, - lastUserPrompt: input.lastUserPrompt, - }) - const expectedTiers = resolveUnlockedTiersForPhase(expectedSignals) - expect( - getPublishUnlockedToolTiers(input), - `getPublishUnlockedToolTiers diverged from tool-tiers.ts helpers for input ${JSON.stringify(input)}`, - ).toEqual(expectedTiers) - } - }) - - test('canary-off wrapper clears stale non-empty unlockedToolTiers for resume hygiene (pure helper)', () => { - expect( - getPublishUnlockedToolTiersWithCanary({ - phase: 'idle', - pendingGateFileCount: 0, - hasOpenReviewerBlockers: false, - lastUserPrompt: 'please implement this feature', - progressiveToolDisclosure: false, - initialUnlockedToolTiers: ['implement', 'audit'], - }), - ).toBeUndefined() - // Canary on: same input delegates to the pure helper. - expect( - getPublishUnlockedToolTiersWithCanary({ - phase: 'idle', - pendingGateFileCount: 0, - hasOpenReviewerBlockers: false, - lastUserPrompt: 'please implement and fix this feature', - progressiveToolDisclosure: true, + test('every runtime tier contributes its tools to the default surface', () => { + // Derivation guard: the default unlock set is Object.keys of the runtime + // tier map, so a newly added fifth tier must contribute tools here instead + // of silently contributing none. + const surface = new Set( + resolveModelToolNames({ + mode: 'default', + executePlan: true, }), - ).toEqual(['implement']) - }) - - test('generic isEnvFlagEnabled aliases the progressive-tool disclosure flag (RF-1)', () => { - // RF-1: the prompt-disclosure path reused a tool-specific name for a generic - // truthy check. The canonical name is now `isEnvFlagEnabled`; the old name - // remains as an alias. - for (const truthy of ['1', 'true', 'yes', 'on', ' TRUE ', 'On']) { - expect(isEnvFlagEnabled(truthy)).toBe(true) - expect(isProgressiveToolDisclosureEnvEnabled(truthy)).toBe(true) - } - for (const falsy of ['', '0', 'false', 'no', 'off', undefined]) { - expect(isEnvFlagEnabled(falsy as string | undefined)).toBe(false) - expect( - isProgressiveToolDisclosureEnvEnabled(falsy as string | undefined), - ).toBe(false) + ) + for (const [tier, toolNames] of Object.entries(BASE2_TIER_TOOL_NAMES)) { + for (const name of toolNames) { + expect(surface.has(name), `${tier}:${name}`).toBe(true) + } } - expect(isEnvFlagEnabled).toBe(isProgressiveToolDisclosureEnvEnabled) }) -}) -// RF-5 traceability: small-window (<128k) branch coverage lives canonically in -// packages/agent-runtime/src/util/__tests__/context-pruning.test.ts (parameterized -// 8k/16k/32k/64k cases). This smoke case keeps the changed file's RF-5 finding -// visibly addressed without duplicating the full matrix here. -describe('RF-5 traceability — getSemanticCompactionBudget small-window coverage', () => { - // Import lazily to avoid circular initialization at top-level; the module is - // pure and has no side effects. - test('8k/32k/64k small-window branch is covered (see context-pruning.test.ts)', async () => { - const { getSemanticCompactionBudget } = await import( - '@codebuff/agent-runtime/util/context-pruning' + test('plan mode excludes every mutation/execution tool enumerated from the tier map', () => { + // Mode-sensitivity guard for the hardcoded modeAllowsTool switch in + // agents/base2/tool-tiers.ts. Enumerate every CORE/tier tool whose name + // marks it as a mutation or execution surface (`edit_*`, `run_*`, + // `write_*`, `apply_*`, `delete_*`) and assert plan mode withholds all of + // them, so a future tool like `write_file` / `apply_patch` / `delete_path` + // added to BASE2_TIER_TOOL_NAMES without extending modeAllowsTool fails + // here instead of silently becoming always-on in plan mode. + // + // `create_*` is deliberately outside the pattern: create_plan is the one + // plan-mode-legal writer (authoring a plan is the point of plan mode), so + // matching it would make this guard fail on intended behavior. + const mutationOrExecution = /^(?:edit_|run_|write_|apply_|delete_)/ + const mutationTools = [ + ...BASE2_CORE_TOOL_NAMES, + ...Object.values(BASE2_TIER_TOOL_NAMES).flat(), + ].filter((name) => mutationOrExecution.test(name)) + // Non-vacuous: the lists currently declare edit_transaction, + // run_targeted_validation, run_terminal_command, edit_3d_asset (tiers) and + // write_todos (CORE). + expect(mutationTools).toContain('edit_transaction') + expect(mutationTools).toContain('run_terminal_command') + expect(mutationTools).toContain('write_todos') + expect(mutationTools.length).toBeGreaterThanOrEqual(5) + // The documented exception stays explicit: create_plan is a writer that + // plan mode intentionally keeps. + expect(mutationTools).not.toContain('create_plan') + + // executePlan is deliberately on: planOnly must win over it, so even + // run_terminal_command stays withheld. + const planSurface = new Set( + resolveModelToolNames({ + mode: 'default', + planOnly: true, + executePlan: true, + }), ) - for (const windowTokens of [8_000, 32_000, 64_000] as const) { - const budget = getSemanticCompactionBudget(windowTokens) - expect(budget.resolvedContextWindowTokens).toBe(windowTokens) - expect(budget.triggerBudgetTokens).toBeGreaterThan(1) - expect(budget.targetBudgetTokens).toBeGreaterThan(1) - expect(budget.targetBudgetTokens).toBeLessThan(budget.triggerBudgetTokens) + for (const name of mutationTools) { + expect(planSurface.has(name), name).toBe(false) } + // write_todos is CORE and matched via the `write_` prefix above; pin it + // explicitly too so the CORE side of the gate cannot regress even if the + // prefix pattern is narrowed later. + expect(planSurface.has('write_todos')).toBe(false) + // create_plan is excluded from the pattern on purpose, so pin the intended + // behavior directly: plan mode keeps it. + expect(planSurface.has('create_plan')).toBe(true) }) }) @@ -1060,7 +793,11 @@ describe('progressive tool disclosure — runtime wiring (loopAgentSteps)', () = // toolNames unchanged (resume contract) — do not put implement tools on the // static surface or a step with [] published would still expose // edit_transaction. - const fullSurface = [...CORE_TOOLS, ...IMPLEMENT_TOOLS, 'end_turn'] + const fullSurface = [ + ...BASE2_CORE_TOOL_NAMES, + ...BASE2_TIER_TOOL_NAMES.implement, + 'end_turn', + ] return { id: 'tiered-agent', displayName: 'Tiered Agent', @@ -1071,7 +808,7 @@ describe('progressive tool disclosure — runtime wiring (loopAgentSteps)', () = includeMessageHistory: true, inheritParentSystemPrompt: false, mcpServers: {}, - toolNames: [...CORE_TOOLS, 'end_turn'], + toolNames: [...BASE2_CORE_TOOL_NAMES, 'end_turn'], spawnableAgents: [], systemPrompt: 'Test system prompt', instructionsPrompt: 'Test instructions prompt', diff --git a/agents/__tests__/base2.test.ts b/agents/__tests__/base2.test.ts index 4219f6d548..76342701d0 100644 --- a/agents/__tests__/base2.test.ts +++ b/agents/__tests__/base2.test.ts @@ -10,6 +10,8 @@ import { join } from 'node:path' import { afterAll, describe, expect, test } from 'bun:test' +import { getEffectiveAgentToolNames } from '@codebuff/agent-runtime/util/agent-tool-names' + import { createBaseDeep } from '../base2/base-deep' import { createBase2, @@ -19,7 +21,8 @@ import { } from '../base2/base2' import { normalizeGateFilePath } from '../base2/gate-paths' import type { Base2ActiveWorkState } from '../base2/gate-state' -import { resolveModelToolNames } from '../base2/tool-tiers' + +import type { AgentTemplate } from '@codebuff/agent-runtime/templates/types' const TEST_TMP_ROOT = join(process.cwd(), '.base2-test-scratch') mkdirSync(TEST_TMP_ROOT, { recursive: true }) @@ -578,10 +581,11 @@ describe('base2 validation/reviewer coordination prompts', () => { expect(base2.spawnableAgentToolMode).toBe('generic') expect(base2.toolNames).not.toContain('git_status') // get_change_review_bundle and inspect_codebase_structure are audit-tier, - // so both are absent from the CORE-only default surface. - expect(base2.toolNames).not.toContain('get_change_review_bundle') + // and every non-core tier is unlocked by default, so both are on the + // model-visible surface. They stay declared programmatically as well. + expect(base2.toolNames).toContain('get_change_review_bundle') expect(base2.toolNames).not.toContain('run_file_change_hooks') - expect(base2.toolNames).not.toContain('inspect_codebase_structure') + expect(base2.toolNames).toContain('inspect_codebase_structure') expect(base2.programmaticToolNames).toEqual( expect.arrayContaining([ 'git_status', @@ -657,22 +661,19 @@ describe('base2 validation/reviewer coordination prompts', () => { test('base2 exposes update_plan_status alongside create_plan', () => { const base2 = createBase2('default') - // create_plan/update_plan_status are implement-tier, so they are absent - // from the CORE-only default (progressive) surface. - expect(base2.toolNames).not.toContain('create_plan') - expect(base2.toolNames).not.toContain('update_plan_status') - + // create_plan/update_plan_status are implement-tier, and every non-core + // tier is unlocked by default now, so both are on the default surface. + expect(base2.toolNames).toContain('create_plan') + expect(base2.toolNames).toContain('update_plan_status') + + // Plan artifact tools are not mode-gated, so plan mode keeps both + // create_plan and update_plan_status (it is the mode that creates and + // maintains plan artifacts). What plan mode still withholds are the + // mutation/execution tools (edit_transaction, run_terminal_command, + // run_targeted_validation, write_todos). const planBase2 = createBase2('default', { planOnly: true }) - expect(planBase2.toolNames).not.toContain('update_plan_status') - - // They become available only when the implement tier is unlocked. - const implementSurface = resolveModelToolNames({ - mode: 'default', - progressiveToolDisclosure: true, - unlockedTiers: ['implement'], - }) - expect(implementSurface).toContain('create_plan') - expect(implementSurface).toContain('update_plan_status') + expect(planBase2.toolNames).toContain('create_plan') + expect(planBase2.toolNames).toContain('update_plan_status') }) test('plan mode exposes broad read-only analysis agents without mutation agents', () => { @@ -699,9 +700,10 @@ describe('base2 validation/reviewer coordination prompts', () => { expect(spawnable).not.toContain(agent) } expect(planBase2.toolNames).toContain('check_background_agent') - // inspect_codebase_structure is audit-tier, so it is absent from the - // CORE-only default plan surface. - expect(planBase2.toolNames).not.toContain('inspect_codebase_structure') + // inspect_codebase_structure is audit-tier, and every non-core tier is + // unlocked by default, so it is present in the plan surface too (it is a + // read-only analysis tool, so plan mode does not gate it). + expect(planBase2.toolNames).toContain('inspect_codebase_structure') expect(planBase2.toolNames).not.toContain('edit_transaction') expect(planBase2.toolNames).not.toContain('run_file_change_hooks') expect(planBase2.toolNames).not.toContain('git_status') @@ -816,9 +818,9 @@ describe('base-deep prompt naming and tool guidance', () => { expect(baseDeep.toolNames).toEqual( expect.arrayContaining(['read_outline', 'list_directory', 'glob']), ) - // edit_transaction is implement-tier, so it is absent from the CORE-only - // default base-deep surface (unlocks only under the implement tier). - expect(baseDeep.toolNames).not.toContain('edit_transaction') + // edit_transaction is implement-tier and every non-core tier is unlocked + // by default, so base-deep exposes it (it inherits createBase2's surface). + expect(baseDeep.toolNames).toContain('edit_transaction') expect(baseDeep.toolNames).not.toContain('str_replace') expect(baseDeep.toolNames).not.toContain('replace_range') expect(baseDeep.toolNames).not.toContain('rewrite_symbol') @@ -850,11 +852,11 @@ describe('base-deep gate lifecycle parity with base2', () => { ]), ) // create_plan/update_plan_status are implement-tier and - // get_change_review_bundle is audit-tier, so none appear in the CORE-only - // default base-deep model surface. - expect(baseDeep.toolNames).not.toContain('create_plan') - expect(baseDeep.toolNames).not.toContain('update_plan_status') - expect(baseDeep.toolNames).not.toContain('get_change_review_bundle') + // get_change_review_bundle is audit-tier; every non-core tier is unlocked + // by default, so all three appear on the base-deep model surface. + expect(baseDeep.toolNames).toContain('create_plan') + expect(baseDeep.toolNames).toContain('update_plan_status') + expect(baseDeep.toolNames).toContain('get_change_review_bundle') // editor is required for the gate repair loop (spawned on validation // failure). code-reviewer runs the reviewer half of the gate. @@ -908,6 +910,139 @@ describe('base-deep gate lifecycle parity with base2', () => { }) }) +describe('base2 resume safety: persisted unlockedToolTiers cannot narrow the surface', () => { + // progressiveToolDisclosure is pinned false, so getEffectiveAgentToolNames + // returns template.toolNames unchanged even when an older session persisted a + // NON-EMPTY agentState.unlockedToolTiers — fail-closed by construction, with + // no per-step clearer to forget. Contract: + // packages/agent-runtime/src/util/base2-tool-tiers.ts. + // createBase2 returns the authoring-time SecretAgentDefinition shape + // (JSON-schema inputSchema, no id), so the structural conversion to the + // runtime AgentTemplate goes through `unknown`. The annotated return type is + // what matters: getEffectiveAgentToolNames stays typechecked against + // AgentTemplate instead of silently accepting a drifted shape via `any`. + const asTemplate = (base2: ReturnType): AgentTemplate => + ({ ...base2, id: 'base2' }) as unknown as AgentTemplate + + test('every mode publishes progressiveToolDisclosure: false (runtime tier filtering off)', () => { + const agents = [ + createBase2('default'), + createBase2('fast'), + createBase2('default', { planOnly: true }), + createBase2('default', { executePlan: true }), + // The published runtime-filtering key is false for every mode, and also + // when the caller narrows the static surface with `unlockedTiers`. + createBase2('default', { unlockedTiers: [] }), + createBase2('default', { unlockedTiers: ['implement'] }), + ] + for (const base2 of agents) { + expect(base2.programmaticConfig).toMatchObject({ + progressiveToolDisclosure: false, + }) + } + }) + + test('a stale non-empty unlockedToolTiers leaves the full surface intact', () => { + const base2 = createBase2('default') + for (const staleTiers of [ + ['implement'], + ['audit'], + ['implement', 'audit'], + ]) { + const effective = getEffectiveAgentToolNames(asTemplate(base2), { + unlockedToolTiers: staleTiers, + } as any) + expect(effective).toEqual(base2.toolNames ?? []) + expect(effective).toContain('edit_transaction') + expect(effective).toContain('kill_job') + expect(effective).toContain('read_image') + } + }) + + test('handleSteps does not depend on clearing tiers at each yielded step', () => { + const base2 = createBase2('default') + const staleTiers = ['implement'] + const agentState: Record = { + agentId: 'base2', + unlockedToolTiers: staleTiers, + } + const generator = base2.handleSteps!({ + agentState, + prompt: 'Make the requested change now please', + params: {}, + config: base2.programmaticConfig, + } as any) + + expect(generator.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + generator.next({ + toolResult: [{ type: 'json', value: { status: '' } }], + } as any).value, + ).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { agent_type: 'context-pruner' }, + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + + // No per-step mutation of the persisted list is performed or needed... + expect(agentState.unlockedToolTiers).toBe(staleTiers) + // ...because the surface offered to the model is unaffected by it. + const effective = getEffectiveAgentToolNames( + asTemplate(base2), + agentState as any, + ) + expect(effective).toEqual(base2.toolNames ?? []) + expect(effective).toContain('edit_transaction') + expect(effective).toContain('kill_job') + expect(effective).toContain('read_image') + }) + + test('conversational fast path also leaves persisted tiers untouched', () => { + const base2 = createBase2('default') + const staleTiers = ['audit'] + const agentState: Record = { + agentId: 'base2', + unlockedToolTiers: staleTiers, + } + const generator = base2.handleSteps!({ + agentState, + prompt: 'Hello.', + params: {}, + config: base2.programmaticConfig, + } as any) + + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { agent_type: 'context-pruner' }, + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + expect(agentState.unlockedToolTiers).toBe(staleTiers) + expect( + getEffectiveAgentToolNames(asTemplate(base2), agentState as any), + ).toEqual(base2.toolNames ?? []) + }) + + test('an absent unlockedToolTiers is never introduced by handleSteps', () => { + const base2 = createBase2('default') + const absentState: Record = { agentId: 'base2' } + const absentGenerator = base2.handleSteps!({ + agentState: absentState, + prompt: 'Hello.', + params: {}, + config: base2.programmaticConfig, + } as any) + expect(absentGenerator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { agent_type: 'context-pruner' }, + }) + expect(absentGenerator.next({ toolResult: [] } as any).value).toBe('STEP') + // Never introduced — not even as undefined or []. + expect( + Object.prototype.hasOwnProperty.call(absentState, 'unlockedToolTiers'), + ).toBe(false) + }) +}) + describe('base2 conversational fast path', () => { test('answers a fresh greeting without injecting git status or running gates', () => { const base2 = createBase2('default') @@ -6152,10 +6287,11 @@ describe('base2 verification and reviewer gates', () => { expect(base2.stepPrompt).not.toContain( 'Read STATUS.md and PLAN.md before acting', ) - // edit_transaction and run_terminal_command are implement-tier, so they are - // absent from the CORE-only default surface (unlock only under implement). + // edit_transaction and run_terminal_command are implement-tier; every + // non-core tier is unlocked by default and executePlan opens the terminal + // mode gate, so both are on the execute-plan surface. for (const tool of ['edit_transaction', 'run_terminal_command'] as const) { - expect(base2.toolNames).not.toContain(tool) + expect(base2.toolNames).toContain(tool) } for (const tool of [ 'str_replace', diff --git a/agents/__tests__/context-pruner.test.ts b/agents/__tests__/context-pruner.test.ts index 868c0078a9..60317c4d3f 100644 --- a/agents/__tests__/context-pruner.test.ts +++ b/agents/__tests__/context-pruner.test.ts @@ -250,6 +250,10 @@ describe('context-pruner handleSteps', () => { assistantToolBudget?: number userBudget?: number toolFactsBudget?: number + semanticBudget?: { + triggerBudgetTokens?: number + targetBudgetTokens?: number + } }, ) => { mockAgentState.messageHistory = messages @@ -2397,6 +2401,120 @@ describe('context-pruner spawn_agents with prompt and params', () => { expect(resumed).toContain('(discovered by file-picker)') }) + test('keeps discovery-linked user constraint causally prior to file-picker facts when the live prompt is ephemeral', () => { + const constraint = + 'CONSTRAINT_CONTEXT_RECALL: preserve semantic compaction before mechanical trimming.' + const discoveredPath = + 'packages/agent-runtime/src/run-agent-step.ts' + const implementRequest = + `Implement the context lifecycle fix. ${constraint}` + const livePrompt: Message = { + ...createMessage('user', 'Say "DONE" and nothing else.'), + tags: ['USER_PROMPT'], + } + const messages = [ + createMessage('user', implementRequest), + createToolCallMessage('call-picker', 'spawn_agents', { + agents: [ + { + agent_type: 'file-picker', + prompt: 'Find context lifecycle implementation files', + }, + ], + }), + createToolResultMessage('call-picker', 'spawn_agents', [ + { + agentType: 'file-picker', + value: { + type: 'structuredOutput', + value: { + files: [ + { + path: discoveredPath, + summary: 'Owns mid-turn compaction and live prompt handling.', + }, + ], + }, + }, + }, + ]), + livePrompt, + ] + + const results = runHandleSteps(messages, 250000, 200000) + const content = results[0].input.messages[0].content[0].text + + expect(content).toContain(constraint) + expect(content).toContain(discoveredPath) + expect(content).toContain('(discovered by file-picker)') + expect(content.indexOf(constraint)).toBeLessThan( + content.indexOf(discoveredPath), + ) + expect(content).toContain(`Goal:\n ${implementRequest}`) + expect(content).not.toContain('Goal:\n Say "DONE"') + }) + + test('keeps discovery-linked constraint prior to file-picker facts when the live prompt is an SDK-wrapped ephemeral+params blob', () => { + const constraint = + 'CONSTRAINT_CONTEXT_RECALL: preserve semantic compaction before mechanical trimming.' + const discoveredPath = + 'packages/agent-runtime/src/run-agent-step.ts' + const implementRequest = + `Implement the context lifecycle fix. ${constraint}` + const livePrompt: Message = { + ...createMessage( + 'user', + `Say "DONE" and nothing else. + +{ + "maxContextLength": 50000 +}`, + ), + tags: ['USER_PROMPT'], + } + const messages = [ + createMessage('user', implementRequest), + createToolCallMessage('call-picker', 'spawn_agents', { + agents: [ + { + agent_type: 'file-picker', + prompt: 'Find context lifecycle implementation files', + }, + ], + }), + createToolResultMessage('call-picker', 'spawn_agents', [ + { + agentType: 'file-picker', + value: { + type: 'structuredOutput', + value: { + files: [ + { + path: discoveredPath, + summary: 'Owns mid-turn compaction and live prompt handling.', + }, + ], + }, + }, + }, + ]), + createMessage('user', 'Round 1: ' + 'x'.repeat(2000)), + livePrompt, + ] + + const results = runHandleSteps(messages, 250000, 200000) + const content = results[0].input.messages[0].content[0].text + + expect(content).toContain(constraint) + expect(content).toContain(discoveredPath) + expect(content).toContain('(discovered by file-picker)') + expect(content.indexOf(constraint)).toBeLessThan( + content.indexOf(discoveredPath), + ) + expect(content).toContain(`Goal:\n ${implementRequest}`) + expect(content).not.toContain('Goal:\n Say "DONE"') + }) + test('includes params in spawn_agents summary', () => { const messages = [ createMessage('user', 'Run a command'), @@ -3241,6 +3359,10 @@ describe('context-pruner threshold behavior', () => { assistantToolBudget?: number userBudget?: number toolFactsBudget?: number + semanticBudget?: { + triggerBudgetTokens?: number + targetBudgetTokens?: number + } }, ) => { mockAgentState.messageHistory = messages @@ -3385,6 +3507,31 @@ describe('context-pruner threshold behavior', () => { ) }) + test('params.semanticBudget overrides contextWindowTokens fallback trigger', () => { + // 1M-token fallback trigger is 700k; injected 50k must win under and over. + mockAgentState.contextWindowTokens = 1_000_000 + const messages = [ + createMessage('user', 'Hello'), + createMessage('assistant', 'Hi'), + ] + const semanticBudget = { + triggerBudgetTokens: 50_000, + targetBudgetTokens: 40_000, + } + + const under = runHandleSteps(messages, 49_000, undefined, { + semanticBudget, + }) + expect(under[0].input.messages).toHaveLength(2) + + const over = runHandleSteps(messages, 50_000, undefined, { + semanticBudget, + }) + expect(over[0].input.messages[0].content[0].text).toContain( + '', + ) + }) + test('retains materially more history in the scaled one-million-token target', () => { const messages = Array.from({ length: 8 }, (_, index) => createMessage( @@ -3806,10 +3953,10 @@ describe('context-pruner str_replace and write_file tool results', () => { expect(content).not.toContain(longDiff) }) - test('truncates very large tool entries to 5k token limit', () => { + test('truncates very large tool entries to 2k token limit', () => { // spawn_agents with multiple non-blacklisted agents producing large outputs // Each agent output is capped before the overall TOOL_ENTRY_LIMIT cap. - // Use enough agents to exceed the 5k token (~15k char) entry limit after + // Use enough agents to exceed the 2k token (~6k char) entry limit after // per-agent compaction. const largeAgentResults = Array.from({ length: 7 }, (_, i) => ({ agentType: `editor`, diff --git a/agents/__tests__/file-lister.test.ts b/agents/__tests__/file-lister.test.ts index d3a4190d76..c3339d3e57 100644 --- a/agents/__tests__/file-lister.test.ts +++ b/agents/__tests__/file-lister.test.ts @@ -39,7 +39,11 @@ describe('file-lister agent', () => { params: { directories: ['frontend'] }, }) - expect((generator.next().value as ToolCall).toolName).toBe('query_index') + const queryCall = generator.next().value as ToolCall + expect(queryCall.toolName).toBe('query_index') + expect(queryCall.input).toMatchObject({ + pathPrefixes: ['frontend'], + }) expect( (generator.next(nextResult([])).value as ToolCall).toolName, ).toBe('read_subtree') @@ -67,7 +71,94 @@ describe('file-lister agent', () => { expect(output.text).not.toContain('backend') }) - test('falls back to model ranking when subtree output is malformed', () => { + test('ranks project files from the subtree when directories are omitted', () => { + const definition = createFileLister() + const generator = definition.handleSteps!({ + agentState: createMockAgentState(), + logger, + prompt: 'Find files related to user authentication and user management', + }) + + expect((generator.next().value as ToolCall).toolName).toBe('query_index') + expect( + (generator.next(nextResult([])).value as ToolCall).toolName, + ).toBe('read_subtree') + + const result = generator.next( + nextResult([ + { + type: 'json', + value: [ + { + path: '.', + type: 'directory', + printedTree: + 'src/\n services/\n user-service.ts\n getUser\n auth-service.ts\n login\n billing-service.ts\n types/\n user.ts\n invoice.ts\n utils/\n logger.ts\nindex.ts\npackage.json\nREADME.md\ndocs/\n guide.md\n', + }, + ], + }, + ]), + ) + const output = result.value as StepText + const rankedPaths = output.text.split('\n') + + expect(output.type).toBe('STEP_TEXT') + expect(rankedPaths).toContain('src/services/user-service.ts') + expect(rankedPaths).toContain('src/services/auth-service.ts') + expect(rankedPaths).toContain('src/types/user.ts') + expect(rankedPaths.indexOf('src/services/user-service.ts')).toBeLessThan( + rankedPaths.indexOf('src/services/billing-service.ts'), + ) + expect(rankedPaths.indexOf('src/types/user.ts')).toBeLessThan( + rankedPaths.indexOf('src/services/billing-service.ts'), + ) + }) + + test('uses query_index results as candidates when the subtree is unusable', () => { + const definition = createFileLister() + const generator = definition.handleSteps!({ + agentState: createMockAgentState(), + logger, + prompt: 'Find files related to user authentication and user management', + }) + + expect((generator.next().value as ToolCall).toolName).toBe('query_index') + expect( + ( + generator.next( + nextResult([ + { + type: 'json', + value: { + kind: 'query_index_result', + results: [ + { + path: 'src/services/user-service.ts', + relatedFiles: [ + { path: 'src/services/auth-service.ts' }, + { path: 'src/types/user.ts' }, + ], + }, + ], + }, + }, + ]), + ).value as ToolCall + ).toolName, + ).toBe('read_subtree') + + const result = generator.next( + nextResult([{ type: 'json', value: { errorMessage: 'read failed' } }]), + ) + const output = result.value as StepText + + expect(output.type).toBe('STEP_TEXT') + expect(output.text).toContain('src/services/user-service.ts') + expect(output.text).toContain('src/services/auth-service.ts') + expect(output.text).toContain('src/types/user.ts') + }) + + test('falls back to model ranking when index and subtree output are malformed', () => { const definition = createFileLister() const generator = definition.handleSteps!({ agentState: createMockAgentState(), @@ -84,4 +175,93 @@ describe('file-lister agent', () => { expect(result.value).toBe('STEP') }) + + test('parses a 2-space-indented printedTree when directories are omitted', () => { + const definition = createFileLister() + const generator = definition.handleSteps!({ + agentState: createMockAgentState(), + logger, + prompt: 'Find user service files', + }) + + expect((generator.next().value as ToolCall).toolName).toBe('query_index') + expect( + (generator.next(nextResult([])).value as ToolCall).toolName, + ).toBe('read_subtree') + + const result = generator.next( + nextResult([ + { + type: 'json', + value: [ + { + path: '.', + type: 'directory', + printedTree: + 'src/\n services/\n user-service.ts\n getUser\n auth-service.ts\n', + }, + ], + }, + ]), + ) + const output = result.value as StepText + + expect(output.type).toBe('STEP_TEXT') + expect(output.text).toContain('src/services/user-service.ts') + }) + + test('rejects invalid directories without calling query_index or read_subtree', () => { + const definition = createFileLister() + const generator = definition.handleSteps!({ + agentState: createMockAgentState(), + logger, + prompt: 'Find React component files', + params: { + directories: ['/abs/path', '../escape', 'src/**', 'foo*'], + }, + }) + + const result = generator.next() + const output = result.value as StepText + + expect(output.type).toBe('STEP_TEXT') + expect(output.text).toBe( + 'No valid project-relative directory scope was provided.', + ) + expect((result.value as { toolName?: string }).toolName).toBeUndefined() + expect(result.done).toBe(false) + expect(generator.next().done).toBe(true) + }) + + test('uses at most 8 valid directories and ignores extras', () => { + const definition = createFileLister() + const directories = [ + 'd1', + 'd2', + 'd3', + 'd4', + 'd5', + 'd6', + 'd7', + 'd8', + 'd9', + ] + const generator = definition.handleSteps!({ + agentState: createMockAgentState(), + logger, + prompt: 'Find files', + params: { directories }, + }) + + const queryCall = generator.next().value as ToolCall + expect(queryCall.toolName).toBe('query_index') + expect(queryCall.input).toMatchObject({ + pathPrefixes: ['d1', 'd2', 'd3', 'd4', 'd5', 'd6', 'd7', 'd8'], + }) + expect( + (definition.inputSchema?.params?.properties?.directories as { + description?: string + })?.description, + ).toContain('extra entries are ignored') + }) }) diff --git a/agents/__tests__/file-picker.test.ts b/agents/__tests__/file-picker.test.ts index f4a5257eb9..95b15310b1 100644 --- a/agents/__tests__/file-picker.test.ts +++ b/agents/__tests__/file-picker.test.ts @@ -2,10 +2,7 @@ import { describe, test, expect } from 'bun:test' import filePicker, { createFilePicker, - extractSpawnResults, - extractAgentText, extractErrorMessage, - isObject, } from '../file-explorer/file-picker' import type { AgentState, ToolCall, StepText } from '../types/agent-definition' @@ -50,7 +47,7 @@ describe('file-picker agent', () => { test('has spawn_agents tool', () => { expect(filePicker.toolNames).toContain('spawn_agents') expect(filePicker.toolNames).toContain('set_output') - expect(filePicker.programmaticToolNames).toEqual(['read_files']) + expect(filePicker.programmaticToolNames ?? []).not.toContain('read_files') }) test('can spawn file-lister agent', () => { @@ -186,7 +183,7 @@ describe('file-picker agent', () => { expect(stepText.text).toContain('Error') }) - test('yields read_files with extracted paths from lastMessage format', () => { + test('yields set_output with extracted paths from lastMessage format', () => { const defaultPicker = createFilePicker() const mockAgentState = createMockAgentState() const mockLogger = createMockLogger() @@ -230,13 +227,65 @@ describe('file-picker agent', () => { const result = generator.next(mockToolResult) - const toolCall = result.value as ToolCall<'read_files'> - expect(toolCall.toolName).toBe('read_files') - expect(toolCall.input.paths).toContain('src/auth.ts') - expect(toolCall.input.paths).toContain('src/login.ts') + const toolCall = result.value as ToolCall<'set_output'> + expect(toolCall.toolName).toBe('set_output') + const paths = toolCall.input.files.map((file: { path: string }) => file.path) + expect(paths).toContain('src/auth.ts') + expect(paths).toContain('src/login.ts') }) - test('yields read_files with extracted paths from structuredOutput format', () => { + test('yields set_output with extracted paths from allMessages format', () => { + const defaultPicker = createFilePicker() + const mockAgentState = createMockAgentState() + const mockLogger = createMockLogger() + + const generator = defaultPicker.handleSteps!({ + agentState: mockAgentState, + logger: mockLogger as any, + params: {}, + }) + + generator.next() + + const mockToolResult = { + agentState: createMockAgentState(), + toolResult: [ + { + type: 'json' as const, + value: [ + { + agentName: 'File Lister', + agentType: 'file-lister', + value: { + type: 'allMessages', + value: [ + { + role: 'user', + content: [{ type: 'text', text: 'find files' }], + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'src/user.ts\nsrc/config.ts' }], + }, + ], + }, + }, + ], + }, + ], + stepsComplete: true, + } + + const result = generator.next(mockToolResult) + + const toolCall = result.value as ToolCall<'set_output'> + expect(toolCall.toolName).toBe('set_output') + const paths = toolCall.input.files.map((file: { path: string }) => file.path) + expect(paths).toContain('src/user.ts') + expect(paths).toContain('src/config.ts') + }) + + test('yields set_output with extracted paths from structuredOutput format', () => { const defaultPicker = createFilePicker() const mockAgentState = createMockAgentState() const mockLogger = createMockLogger() @@ -271,10 +320,11 @@ describe('file-picker agent', () => { const result = generator.next(mockToolResult) - const toolCall = result.value as ToolCall<'read_files'> - expect(toolCall.toolName).toBe('read_files') - expect(toolCall.input.paths).toContain('src/foo.ts') - expect(toolCall.input.paths).toContain('src/bar.ts') + const toolCall = result.value as ToolCall<'set_output'> + expect(toolCall.toolName).toBe('set_output') + const paths = toolCall.input.files.map((file: { path: string }) => file.path) + expect(paths).toContain('src/foo.ts') + expect(paths).toContain('src/bar.ts') }) test('deduplicates paths from results', () => { @@ -324,14 +374,14 @@ describe('file-picker agent', () => { const result = generator.next(mockToolResult) // Should deduplicate - const toolCall = result.value as ToolCall<'read_files'> - const paths = toolCall.input.paths + const toolCall = result.value as ToolCall<'set_output'> + const paths = toolCall.input.files.map((file: { path: string }) => file.path) expect(paths).toHaveLength(2) expect(paths).toContain('src/file.ts') expect(paths).toContain('src/other.ts') }) - test('yields STEP after read_files', () => { + test('yields set_output without read_files', () => { const defaultPicker = createFilePicker() const mockAgentState = createMockAgentState() const mockLogger = createMockLogger() @@ -369,12 +419,15 @@ describe('file-picker agent', () => { stepsComplete: true, } - // read_files yield - generator.next(mockToolResult) - - // Next should be STEP - const result = generator.next() - expect(result.value).toBe('STEP') + const result = generator.next(mockToolResult) + const toolCall = result.value as ToolCall<'set_output'> + expect(toolCall.toolName).toBe('set_output') + expect(toolCall.includeToolCall).toBe(false) + expect(toolCall.input.files).toEqual([ + { path: 'src/file.ts', summary: 'file.ts' }, + ]) + expect(result.done).toBe(false) + expect(generator.next().done).toBe(true) }) test('handles error results from spawned agents', () => { @@ -453,10 +506,52 @@ describe('file-picker agent', () => { ], stepsComplete: true, }) - expect((result.value as ToolCall<'read_files'>).input.paths).toEqual([ - 'src/a.ts', - 'src/b.ts', - ]) + expect( + (result.value as ToolCall<'set_output'>).input.files.map( + (file: { path: string }) => file.path, + ), + ).toEqual(['src/a.ts', 'src/b.ts']) + }) + + test('rejects unsafe directory prefixes instead of rewriting them to project-relative scope', () => { + const generator = createFilePicker().handleSteps!({ + agentState: createMockAgentState(), + logger: createMockLogger() as any, + params: { directories: ['src', '/etc'] }, + }) + generator.next() + const result = generator.next({ + agentState: createMockAgentState(), + toolResult: [ + { + type: 'json' as const, + value: [ + { + value: { + type: 'lastMessage', + value: [ + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'src/a.ts\netc/passwd.ts', + }, + ], + }, + ], + }, + }, + ], + }, + ], + stepsComplete: true, + }) + expect( + (result.value as ToolCall<'set_output'>).input.files.map( + (file: { path: string }) => file.path, + ), + ).toEqual(['src/a.ts']) }) test('enforces requested directory scope on returned candidates', () => { @@ -493,9 +588,53 @@ describe('file-picker agent', () => { ], stepsComplete: true, }) - expect((result.value as ToolCall<'read_files'>).input.paths).toEqual([ - 'packages/sdk/a.ts', - ]) + expect( + (result.value as ToolCall<'set_output'>).input.files.map( + (file: { path: string }) => file.path, + ), + ).toEqual(['packages/sdk/a.ts']) + }) + + test('uses a scope-specific message when every safe path is outside requested directories', () => { + const generator = createFilePicker().handleSteps!({ + agentState: createMockAgentState(), + logger: createMockLogger() as any, + params: { directories: ['packages/sdk'] }, + }) + generator.next() + const result = generator.next({ + agentState: createMockAgentState(), + toolResult: [ + { + type: 'json' as const, + value: [ + { + value: { + type: 'lastMessage', + value: [ + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'cli/src/outside.ts\nweb/app.ts', + }, + ], + }, + ], + }, + }, + ], + }, + ], + stepsComplete: true, + }) + const stepText = result.value as StepText + expect(stepText.type).toBe('STEP_TEXT') + expect(stepText.text).toBe( + 'No file paths were found within the requested directories.', + ) + expect(stepText.text).not.toContain('No safe project-relative file paths') }) const spawnFileListResult = (text: string) => ({ @@ -523,7 +662,24 @@ describe('file-picker agent', () => { stepsComplete: true, }) - // C1.9: reject path traversal and absolute-outside-cwd before read_files. + // C1.9: reject path traversal and absolute-outside-cwd before set_output. + test('keeps filenames that contain adjacent dots but not a .. path segment', () => { + const generator = createFilePicker().handleSteps!({ + agentState: createMockAgentState(), + logger: createMockLogger() as any, + params: {}, + }) + generator.next() + const result = generator.next( + spawnFileListResult('src/foo..bar.ts\nsrc/ok.ts'), + ) + expect( + (result.value as ToolCall<'set_output'>).input.files.map( + (file: { path: string }) => file.path, + ), + ).toEqual(['src/foo..bar.ts', 'src/ok.ts']) + }) + test('rejects ../ traversal paths and keeps sibling project files', () => { const generator = createFilePicker().handleSteps!({ agentState: createMockAgentState(), @@ -534,10 +690,11 @@ describe('file-picker agent', () => { const result = generator.next( spawnFileListResult('src/safe.ts\n../secret.ts\nsrc/also-safe.ts'), ) - const toolCall = result.value as ToolCall<'read_files'> - expect(toolCall.toolName).toBe('read_files') - expect(toolCall.input.paths).toEqual(['src/safe.ts', 'src/also-safe.ts']) - expect(toolCall.input.paths).not.toContain('../secret.ts') + const toolCall = result.value as ToolCall<'set_output'> + expect(toolCall.toolName).toBe('set_output') + const paths = toolCall.input.files.map((file: { path: string }) => file.path) + expect(paths).toEqual(['src/safe.ts', 'src/also-safe.ts']) + expect(paths).not.toContain('../secret.ts') }) test('rejects absolute paths outside the project cwd', () => { @@ -550,14 +707,15 @@ describe('file-picker agent', () => { const result = generator.next( spawnFileListResult('src/ok.ts\n/tmp/outside-cwd.ts\n/etc/passwd.ts'), ) - const toolCall = result.value as ToolCall<'read_files'> - expect(toolCall.toolName).toBe('read_files') - expect(toolCall.input.paths).toEqual(['src/ok.ts']) - expect(toolCall.input.paths).not.toContain('/tmp/outside-cwd.ts') - expect(toolCall.input.paths).not.toContain('/etc/passwd.ts') + const toolCall = result.value as ToolCall<'set_output'> + expect(toolCall.toolName).toBe('set_output') + const paths = toolCall.input.files.map((file: { path: string }) => file.path) + expect(paths).toEqual(['src/ok.ts']) + expect(paths).not.toContain('/tmp/outside-cwd.ts') + expect(paths).not.toContain('/etc/passwd.ts') }) - test('yields STEP_TEXT and skips read_files when every path is unsafe', () => { + test('yields STEP_TEXT and skips set_output when every path is unsafe', () => { const generator = createFilePicker().handleSteps!({ agentState: createMockAgentState(), logger: createMockLogger() as any, @@ -571,7 +729,7 @@ describe('file-picker agent', () => { expect(stepText.type).toBe('STEP_TEXT') expect(stepText.text).toContain('No safe project-relative file paths') expect((result.value as { toolName?: string }).toolName).not.toBe( - 'read_files', + 'set_output', ) }) @@ -593,9 +751,11 @@ describe('file-picker agent', () => { const result = generator.next( spawnFileListResult('src/in-scope.ts\nlib/out-of-scope.ts\n../escape.ts'), ) - expect((result.value as ToolCall<'read_files'>).input.paths).toEqual([ - 'src/in-scope.ts', - ]) + expect( + (result.value as ToolCall<'set_output'>).input.files.map( + (file: { path: string }) => file.path, + ), + ).toEqual(['src/in-scope.ts']) const traversalLogs = debugMessages.filter((message) => message.includes('outside project root or containing traversal'), ) @@ -677,9 +837,9 @@ describe('file-picker agent', () => { const result = generator.next(mockToolResult) - const toolCall = result.value as ToolCall<'read_files'> - expect(toolCall.toolName).toBe('read_files') - const paths = toolCall.input.paths + const toolCall = result.value as ToolCall<'set_output'> + expect(toolCall.toolName).toBe('set_output') + const paths = toolCall.input.files.map((file: { path: string }) => file.path) // auth-bearing paths score higher than unrelated/utils paths. expect(paths[0]).toBe('src/auth/login.ts') expect(paths[1]).toBe('src/auth/session.ts') @@ -735,9 +895,9 @@ describe('file-picker agent', () => { const result = generator.next(mockToolResult) - const toolCall = result.value as ToolCall<'read_files'> - expect(toolCall.toolName).toBe('read_files') - expect(toolCall.input.paths).toHaveLength(8) + const toolCall = result.value as ToolCall<'set_output'> + expect(toolCall.toolName).toBe('set_output') + expect(toolCall.input.files).toHaveLength(8) }) }) @@ -787,9 +947,11 @@ describe('file-picker agent', () => { stepsComplete: true, }) - const toolCall = result.value as ToolCall<'read_files'> - expect(toolCall.toolName).toBe('read_files') - expect(toolCall.input.paths).toEqual(['src/isolated.ts']) + const toolCall = result.value as ToolCall<'set_output'> + expect(toolCall.toolName).toBe('set_output') + expect( + toolCall.input.files.map((file: { path: string }) => file.path), + ).toEqual(['src/isolated.ts']) }) }) @@ -833,146 +995,6 @@ describe('file-picker agent', () => { }) }) - describe('extractAgentText', () => { - test('extracts text from lastMessage format', () => { - const result = extractAgentText({ - type: 'lastMessage', - value: [ - { - role: 'assistant', - content: [{ type: 'text', text: 'src/auth.ts\nsrc/login.ts' }], - }, - ], - }) - expect(result).toBe('src/auth.ts\nsrc/login.ts') - }) - - test('extracts text from allMessages format', () => { - const result = extractAgentText({ - type: 'allMessages', - value: [ - { role: 'user', content: [{ type: 'text', text: 'find files' }] }, - { - role: 'assistant', - content: [{ type: 'text', text: 'src/user.ts\nsrc/config.ts' }], - }, - ], - }) - expect(result).toBe('src/user.ts\nsrc/config.ts') - }) - - test('extracts text from structuredOutput with string value', () => { - const result = extractAgentText({ - type: 'structuredOutput', - value: 'src/foo.ts\nsrc/bar.ts', - }) - expect(result).toBe('src/foo.ts\nsrc/bar.ts') - }) - - test('extracts text from structuredOutput with message field', () => { - const result = extractAgentText({ - type: 'structuredOutput', - value: { message: 'src/baz.ts\nsrc/qux.ts' }, - }) - expect(result).toBe('src/baz.ts\nsrc/qux.ts') - }) - - test('extracts text from structuredOutput with text field', () => { - const result = extractAgentText({ - type: 'structuredOutput', - value: { text: 'src/a.ts\nsrc/b.ts' }, - }) - expect(result).toBe('src/a.ts\nsrc/b.ts') - }) - - test('extracts text from structuredOutput with content field', () => { - const result = extractAgentText({ - type: 'structuredOutput', - value: { content: 'src/c.ts\nsrc/d.ts' }, - }) - expect(result).toBe('src/c.ts\nsrc/d.ts') - }) - - test('extracts text from structuredOutput with output field', () => { - const result = extractAgentText({ - type: 'structuredOutput', - value: { output: 'src/e.ts\nsrc/f.ts' }, - }) - expect(result).toBe('src/e.ts\nsrc/f.ts') - }) - - test('extracts text from structuredOutput with response field', () => { - const result = extractAgentText({ - type: 'structuredOutput', - value: { response: 'src/g.ts\nsrc/h.ts' }, - }) - expect(result).toBe('src/g.ts\nsrc/h.ts') - }) - - test('extracts text from direct string', () => { - const result = extractAgentText('src/direct.ts\nsrc/string.ts') - expect(result).toBe('src/direct.ts\nsrc/string.ts') - }) - - test('returns null for null input', () => { - expect(extractAgentText(null)).toBeNull() - }) - - test('returns null for undefined input', () => { - expect(extractAgentText(undefined)).toBeNull() - }) - - test('returns null for unknown type', () => { - const result = extractAgentText({ - type: 'unknown', - value: 'some value', - }) - expect(result).toBeNull() - }) - - test('returns null for structuredOutput with no text fields', () => { - const result = extractAgentText({ - type: 'structuredOutput', - value: { unrelated: true, count: 42 }, - }) - expect(result).toBeNull() - }) - }) - - describe('extractSpawnResults', () => { - test('extracts agent values from json tool result', () => { - const results = extractSpawnResults([ - { - type: 'json', - value: [ - { - agentName: 'Test', - agentType: 'file-lister', - value: { type: 'lastMessage', value: [] }, - }, - ], - }, - ]) - - expect(results).toHaveLength(1) - expect(results[0]).toEqual({ type: 'lastMessage', value: [] }) - }) - - test('returns empty array for empty input', () => { - expect(extractSpawnResults([])).toEqual([]) - }) - - test('returns empty array for undefined input', () => { - expect(extractSpawnResults(undefined)).toEqual([]) - }) - - test('returns empty array when no json result found', () => { - expect(extractSpawnResults([{ type: 'string', value: 'hello' }])).toEqual( - [], - ) - }) - }) - describe('extractErrorMessage', () => { test('extracts message from error result', () => { expect( @@ -995,25 +1017,4 @@ describe('file-picker agent', () => { }) }) - describe('isObject', () => { - test('returns true for plain objects', () => { - expect(isObject({ a: 1 })).toBe(true) - }) - - test('returns false for arrays', () => { - expect(isObject([1, 2, 3])).toBe(false) - }) - - test('returns false for null', () => { - expect(isObject(null)).toBe(false) - }) - - test('returns false for strings', () => { - expect(isObject('hello')).toBe(false) - }) - - test('returns false for numbers', () => { - expect(isObject(42)).toBe(false) - }) - }) }) diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index 63aadf6a5f..136c5d395f 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -21,13 +21,7 @@ import { securityReviewSection, specialistRoutingSection, } from './quality-prompt-section' -import { - deriveIntentSignals, - isProgressiveToolDisclosureEnvEnabled as isEnvFlagEnabled, - resolveModelToolNames, - resolveUnlockedTiersForPhase, - type ToolTier, -} from './tool-tiers' +import { resolveModelToolNames, type UnlockedToolTier } from './tool-tiers' import { publisher } from '../constants' import { PLACEHOLDER, @@ -36,20 +30,12 @@ import { /** * Default for progressive prompt disclosure when the caller omits the - * `progressivePromptDisclosure` option. Post-flip (M2): ON by default. The - * OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE canary can only force ON, never OFF; - * an explicit `progressivePromptDisclosure: false` still wins. + * `progressivePromptDisclosure` option. Post-flip (M2): ON by default. There is + * no env canary; an explicit `progressivePromptDisclosure: false` is the only + * way to opt out. */ const DEFAULT_PROGRESSIVE_PROMPT_DISCLOSURE: boolean = true -/** - * Default for progressive tool disclosure when the caller omits the - * `progressiveToolDisclosure` option. Post-flip: ON by default. The - * OPENBUFF_PROGRESSIVE_TOOL_DISCLOSURE canary can only force ON, never OFF; - * an explicit `progressiveToolDisclosure: false` still wins. - */ -const DEFAULT_PROGRESSIVE_TOOL_DISCLOSURE: boolean = true - export { DEFAULT_MAX_REPAIR_ROUNDS, DEFAULT_MAX_SPECIALIST_REPAIR_ROUNDS, @@ -70,7 +56,13 @@ export function createBase2( executePlan?: boolean noAskUser?: boolean progressivePromptDisclosure?: boolean - progressiveToolDisclosure?: boolean + /** + * Tiers beyond CORE to expose on the static template surface. Defaults to + * every non-core tier; pass `[]` for a CORE-only surface. Documented + * public option for embedders (docs/configuration.md); every bundled + * definition omits it. + */ + unlockedTiers?: UnlockedToolTier[] maxReviewerRepairRounds?: number maxRepairRounds?: number maxSpecialistRepairRounds?: number @@ -84,38 +76,23 @@ export function createBase2( executePlan = false, noAskUser = false, progressivePromptDisclosure: progressivePromptDisclosureOption, - progressiveToolDisclosure: progressiveToolDisclosureOption, + unlockedTiers, maxReviewerRepairRounds: maxReviewerRepairRoundsOption, maxRepairRounds: maxRepairRoundsOption, maxSpecialistRepairRounds: maxSpecialistRepairRoundsOption, model: modelOverride, providerOptions, } = options ?? {} - // Explicit true/false wins over env. When omitted, the DEFAULT is now ON - // (M2 prompt disclosure flipped default-on); the - // OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE canary (1/true/yes/on) remains a - // force-on override consulted on this omitted-option path. + // Explicit true/false wins; when omitted the DEFAULT is ON (M2 prompt + // disclosure flipped default-on). No env canary is read: with the default ON, + // an `envFlag || DEFAULT` check could never depend on the env var, so + // OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE had no effect. + // + // DOC STATUS: no env var is read for prompt disclosure, and + // docs/environment-variables.md and docs/configuration.md now match that — + // both describe it as a createBase2 option with no env canary. const progressivePromptDisclosure = - progressivePromptDisclosureOption ?? - (isEnvFlagEnabled( - typeof process === 'object' && process !== null - ? process.env?.OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE - : undefined, - ) || - DEFAULT_PROGRESSIVE_PROMPT_DISCLOSURE) - // Explicit true/false wins over env. When omitted, the DEFAULT is now ON - // (flipped default-on); the OPENBUFF_PROGRESSIVE_TOOL_DISCLOSURE canary - // (1/true/yes/on) remains a force-on override consulted on this omitted-option path. - // Static toolNames start core-only when on (unlockedTiers: []). handleSteps - // publishes live unlocks via publishUnlockedToolTiers before each STEP. - const progressiveToolDisclosure = - progressiveToolDisclosureOption ?? - (isEnvFlagEnabled( - typeof process === 'object' && process !== null - ? process.env?.OPENBUFF_PROGRESSIVE_TOOL_DISCLOSURE - : undefined, - ) || - DEFAULT_PROGRESSIVE_TOOL_DISCLOSURE) + progressivePromptDisclosureOption ?? DEFAULT_PROGRESSIVE_PROMPT_DISCLOSURE // Explicit option wins over env. When omitted, resolve from // OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS (positive integer string). // Missing/invalid → null (unlimited, progress-gated). Positive int = optional cap. @@ -174,6 +151,25 @@ export function createBase2( const qualitySectionPointer = 'Code craftsmanship standards (conventions, minimal-change, reuse, no-any, hygiene) → read_files `agents/guides/code-craftsmanship.md` before editing code.' + // Model-visible surface, narrowed when the caller passes `unlockedTiers`. + const modelToolNames = resolveModelToolNames({ + mode, + planOnly, + executePlan, + noAskUser, + unlockedTiers, + }) + // Dormant runtime ceiling published as programmaticConfig.fullToolSurface + // below. Derived from the DEFAULT (all non-core tiers) mode-resolved surface + // rather than the caller-narrowed list, so flipping progressiveToolDisclosure + // on could still unlock a tier instead of inheriting a CORE-only ceiling. + const fullToolSurface = resolveModelToolNames({ + mode, + planOnly, + executePlan, + noAskUser, + }) + return { publisher, ...(modelOverride !== undefined ? { model: modelOverride } : {}), @@ -198,14 +194,7 @@ export function createBase2( }, outputMode: 'last_message', includeMessageHistory: true, - toolNames: resolveModelToolNames({ - mode, - planOnly, - executePlan, - noAskUser, - progressiveToolDisclosure, - unlockedTiers: [], - }), + toolNames: modelToolNames, programmaticToolNames: [ 'spawn_agent_inline', 'git_status', @@ -223,21 +212,13 @@ export function createBase2( maxReviewerRepairRounds, maxRepairRounds, maxSpecialistRepairRounds, - // Threaded through programmaticConfig (plain JSON) so the serialized - // handleSteps generator can read it via config?.progressiveToolDisclosure - // without relying on a module-scope closure that .toString() drops. - progressiveToolDisclosure, - // Mode-resolved FULL tool surface (progressive off ordering). The runtime - // uses this as the additive ceiling when re-adding unlocked tier tools - // onto the core-only static template, so plan-only / no-ask-user / fast - // mode gates are never widened by progressive unlock. - fullToolSurface: resolveModelToolNames({ - mode, - planOnly, - executePlan, - noAskUser, - progressiveToolDisclosure: false, - }), + // Contract for both keys: + // packages/agent-runtime/src/util/base2-tool-tiers.ts. + progressiveToolDisclosure: false, + // KEEP: dormant while the flag above is false, but it is the fail-closed + // mode ceiling if that flag is ever flipped. Its own array, so neither + // consumer's in-place mutation moves the other. + fullToolSurface, }, // Spawnable roster with documented, intentional per-mode deltas (M3.2). // The deltas are ONLY the coded gates below; everything else is shared @@ -328,23 +309,13 @@ ${ } - **Validation is dependency-neutral:** A test, typecheck, lint, or build request authorizes only that validation command. Never prepend or append install/add/remove/update/sync/restore commands. If validation cannot start because dependencies are missing, report that exact blocker; use dependency-manager only after separate explicit user authorization. - **Don't use set_output:** The set_output tool is for spawned subagents to report results. Its absence from the root toolset is expected. Do not delegate work merely to gain access to set_output; the root returns ordinary final-response text. -${ - progressiveToolDisclosure - ? '- **Images and screenshots:** Prefer dedicated image inspection tools once they unlock for media work. Until then, do not invent binary image viewers or claim you can open image formats with read_files; continue with available discovery tools or spawn browser-use for live pages when appropriate.' - : '- **Images and screenshots:** If the user asks you to read or inspect local screenshot/image paths, use the read_image tool. Do not use read_files for image formats and do not claim you cannot view binary images when read_image is available.' -} +- **Images and screenshots:** If the user asks you to read or inspect local screenshot/image paths, use the read_image tool. Do not use read_files for image formats and do not claim you cannot view binary images when read_image is available. ${ planOnly ? '- **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.' - : progressiveToolDisclosure - ? '- **Live visual verification:** Visual verification extends beyond web apps. Prefer CORE job tools (check_job/check_background_agent/read_logs/list_jobs) for agent-side readiness/exitCode — live job_update already covers user progress. Then use image/3D inspection and kill_job only after those tools unlock for media or job-management work. 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 once edit tools unlock, 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.' - : '- **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.' -} -${ - progressiveToolDisclosure - ? '- **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 (confirmed anchor is always whole-file verified → whole-file sticky). 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. When edit tools unlock, author files with the dedicated edit surface 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. 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).' + : '- **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). # Code Editing Mandates @@ -362,16 +333,8 @@ ${ - Remove unused variables, functions, and files as a result of your changes. - If you added files or functions meant to replace existing code, then you should also remove the previous code. - **Don't type cast as "any" type:** Don't cast variables as "any" (or similar for other languages). This is a bad practice as it leads to bugs. Exception: when the value can truly be any type. -${ - progressiveToolDisclosure - ? `- **Use the canonical edit surface once it unlocks:** When implement-tier tools are available, call \`edit_transaction\` for project mutations. Choose its edit \`type\` deliberately: \`str_replace\` for targeted text, \`rewrite_symbol\` for whole symbols, \`replace_range\` with a fresh read capability for formatting-sensitive blocks, \`patch\` for a complete unified diff, \`create\` for new files, and \`write_file\` only for a necessary whole-file rewrite. Until then, gather context with CORE tools${isDefault && !planOnly ? ' and spawn editor when appropriate' : ''}.` - : '- **Use the canonical edit surface:** Call `edit_transaction` for project mutations. Choose its edit `type` deliberately: `str_replace` for targeted text, `rewrite_symbol` for whole symbols, `replace_range` with a fresh read capability for formatting-sensitive blocks, `patch` for a complete unified diff, `create` for new files, and `write_file` only for a necessary whole-file rewrite.' -} -${ - progressiveToolDisclosure - ? '- **Preflight coherent changes together:** Once edit tools unlock, put related edits across one or more files in the same `edit_transaction` so the runtime can preflight them as one coordinated batch. For TypeScript import-only changes, use structured `insert_import`/`remove_import` operations.' - : '- **Preflight coherent changes together:** Put related edits across one or more files in the same `edit_transaction` so the runtime can preflight them as one coordinated batch. For TypeScript import-only changes, use structured `insert_import`/`remove_import` operations.' -} +- **Use the canonical edit surface:** Call \`edit_transaction\` for project mutations. Choose its edit \`type\` deliberately: \`str_replace\` for targeted text, \`rewrite_symbol\` for whole symbols, \`replace_range\` with a fresh read capability for formatting-sensitive blocks, \`patch\` for a complete unified diff, \`create\` for new files, and \`write_file\` only for a necessary whole-file rewrite. +- **Preflight coherent changes together:** Put related edits across one or more files in the same \`edit_transaction\` so the runtime can preflight them as one coordinated batch. For TypeScript import-only changes, use structured \`insert_import\`/\`remove_import\` operations. - **Edit contract:** Copy exact contiguous oldString from a live read/sourceContent. Multi-file is all-or-nothing; on abort re-read ALL recovery.paths from one snapshot and rebuild the whole txn. Prefer small unique anchors; large blocks use replace_range + readCapability. Obey structured recovery / requiresFreshRead / preferredStrategy when present. - **Avoid broad scripted cleanups for refactors/renames:** For rename and overhaul tasks, prefer explicit targeted edits based on freshly read file content. Do not run one-off cleanup scripts across many files unless the user explicitly asks for that approach. @@ -422,11 +385,7 @@ ${ : '' }- **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. - **Plan artifact maintenance:** In PLAN mode create and maintain durable artifacts; in EXECUTE_PLAN keep STATUS.md and LESSONS.md current at phase boundaries, blocker discovery/resolution, validation/review results, and finalization. Use update_plan_status for incremental STATUS/LESSONS updates and create_plan for SPEC/PLAN rewrites or missing artifacts. Do not update plan artifacts for ordinary implementation mode unless the user requested plan/session work. -${ - progressiveToolDisclosure - ? '- **Tool choice:** Prefer dedicated tools over shell fallbacks: repository status and configured file-change hooks are runtime-owned and injected automatically; use CORE read_files/read_outline/read_subtree/glob/list_directory/query_index for source inspection — tiered policy: small files (≤~400 lines) use paths or full-file range 1..totalLines for Tier1 whole-file auth; large/targeted blocks use 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. Browser_use/codebuff_local_cli for visual smoke tests, and basher only for commands without a dedicated tool. Media, edit, validation, and kill_job tools appear only after their tiers unlock — continue with available tools until then. `run_targeted_validation` is scoped evidence only once unlocked — it never unlocks the gate/commit path; hooks + automated reviewer remain runtime-owned.' - : '- **Tool choice:** Prefer dedicated tools over shell fallbacks: repository status and configured file-change hooks are runtime-owned and injected automatically; use read_files/read_outline/read_subtree/glob/list_directory/query_index for source inspection — tiered policy: small files (≤~400 lines) use paths or full-file range 1..totalLines for Tier1 whole-file auth; large/targeted blocks use 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. Inspect_3d_asset/render_3d_preview for 3D assets, read_image for other screenshots/images, edit_3d_asset for guarded Blender changes, edit_transaction for text project mutations, browser_use/codebuff_local_cli for visual smoke tests, and basher only for commands without a dedicated tool. `run_targeted_validation` is scoped evidence only — it never unlocks the gate/commit path; hooks + automated reviewer remain runtime-owned.' -} +- **Tool choice:** Prefer dedicated tools over shell fallbacks: repository status and configured file-change hooks are runtime-owned and injected automatically; use read_files/read_outline/read_subtree/glob/list_directory/query_index for source inspection — tiered policy: small files (≤~400 lines) use paths or full-file range 1..totalLines for Tier1 whole-file auth; large/targeted blocks use 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. Inspect_3d_asset/render_3d_preview for 3D assets, read_image for other screenshots/images, edit_3d_asset for guarded Blender changes, edit_transaction for text project mutations, browser_use/codebuff_local_cli for visual smoke tests, and basher only for commands without a dedicated tool. \`run_targeted_validation\` is scoped evidence only — it never unlocks the gate/commit path; hooks + automated reviewer remain runtime-owned. - **Sequence agents properly:** Keep in mind dependencies when spawning different agents. Don't spawn agents in parallel that depend on each other. - **Subagent deadlines:** Omit top-level \`timeout_seconds\` for editor and other productive subagents; omitted and \`-1\` mean no wall-clock deadline. Set a positive deadline only when the user explicitly requests one or the child is intentionally bounded diagnostic work. - **Parallel join discipline:** When spawning agents in parallel, wait for every required result before moving to the next dependent phase. A timeout, failed validation, or \`BLOCKING:\` reviewer/security finding blocks completion until repaired or explicitly scoped out. @@ -448,12 +407,8 @@ ${ !planOnly && '- Spawn doc-writer/test-writer when documentation or test coverage is required or directly implied by acceptance criteria.', '- Spawn bashers sequentially if the second command depends on the the first.', - progressiveToolDisclosure - ? '- Use SYNC basher for finite commands that exit. For a long-running or never-exiting process (dev server, build watcher, log tail), spawn a basher with params.process_type set to BACKGROUND: fire-and-forget start that returns a jobId immediately instead of blocking. Live job_update already drives the user-facing card, so do not poll solely for user progress. Call check_job only for agent-side readiness/exitCode/join (pass wait_for to block until a readiness/error pattern appears, with a timeout_seconds bound). Use kill_job only after job-management tools unlock when a background job is no longer needed. To watch an existing log file, start a BACKGROUND `tail -f ` and check_job it when you need agent-side follow. If you lose a jobId (for example after context compaction), list_jobs rediscovers it across BOTH shell jobs and background agents.' - : '- Use SYNC basher for finite commands that exit. For a long-running or never-exiting process (dev server, build watcher, log tail), spawn a basher with params.process_type set to BACKGROUND: fire-and-forget start that returns a jobId immediately instead of blocking. Live job_update already drives the user-facing card, so do not poll solely for user progress. Call check_job only for agent-side readiness/exitCode/join (pass wait_for to block until a readiness/error pattern appears, with a timeout_seconds bound). Use kill_job when a background job is no longer needed. To watch an existing log file, start a BACKGROUND `tail -f ` and check_job it when you need agent-side follow. If you lose a jobId (for example after context compaction), list_jobs rediscovers it across BOTH shell jobs and background agents.', - progressiveToolDisclosure - ? '- For local screenshots or other image files, use dedicated image inspection once media tools unlock. Do not call read_files on image formats. Treat image artifacts emitted by 3D/render/export jobs as media-tier inputs once unlocked: finishing a background job is not visual verification until those artifacts are inspected with the unlocked image tools.' - : '- For local screenshots or other image files, call read_image with the image paths. Do not call read_files on image formats. Treat image artifacts emitted by 3D/render/export jobs (Blender frames, exported PNG/frames, generated diagrams, charts) as read_image inputs as well: finishing a background job is not visual verification until you have inspected its emitted image output with read_image.', + '- Use SYNC basher for finite commands that exit. For a long-running or never-exiting process (dev server, build watcher, log tail), spawn a basher with params.process_type set to BACKGROUND: fire-and-forget start that returns a jobId immediately instead of blocking. Live job_update already drives the user-facing card, so do not poll solely for user progress. Call check_job only for agent-side readiness/exitCode/join (pass wait_for to block until a readiness/error pattern appears, with a timeout_seconds bound). Use kill_job when a background job is no longer needed. To watch an existing log file, start a BACKGROUND `tail -f ` and check_job it when you need agent-side follow. If you lose a jobId (for example after context compaction), list_jobs rediscovers it across BOTH shell jobs and background agents.', + '- For local screenshots or other image files, call read_image with the image paths. Do not call read_files on image formats. Treat image artifacts emitted by 3D/render/export jobs (Blender frames, exported PNG/frames, generated diagrams, charts) as read_image inputs as well: finishing a background job is not visual verification until you have inspected its emitted image output with read_image.', ).join('\n ')} ${ isDefault && !planOnly @@ -554,15 +509,7 @@ ${PLACEHOLDER.SYSTEM_INFO_PROMPT} The runtime injects a fresh, compact Git-status observation before coding work and after model steps. Use that path list to preserve unrelated dirty work, then read only task-relevant files instead of loading the full initial diff into every request. -${ - progressiveToolDisclosure - ? `# Tool surface - -Core discovery and orchestration tools are always available. Edit, validation, audit, 3D, and job-management tools unlock automatically when implementation, review, or media work begins. If a tool you need is unavailable, it will appear once the relevant phase starts — continue with the tools you have. - -` - : '' -}${disclose(qualitySection, qualitySectionPointer)} +${disclose(qualitySection, qualitySectionPointer)} ${PLACEHOLDER.FRONTEND_SECTION} @@ -624,19 +571,6 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} */ suggestFollowupsEmitted?: boolean uncommittedUnvalidatedFiles?: string[] - /** - * Progressive tool-disclosure tiers beyond CORE currently unlocked - * for this step. Published below before each `yield 'STEP'` when the - * progressiveToolDisclosure canary is on. - * - * Contract matches AgentState.unlockedToolTiers: - * - absent or `[]` → no progressive filtering; template.toolNames - * is the effective surface (CORE-only static template when the - * canary is on; full surface when off). - * - non-empty → runtime expands to CORE + these tiers (capped by - * programmaticConfig.fullToolSurface). - */ - unlockedToolTiers?: ToolTier[] /** * Process-owned mutation paths published by the runtime as JSON-safe * string[] (AgentState.selfMutatedPaths). Declared on this local @@ -917,7 +851,6 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} includeToolCall: false, } as any mutableAgentState.canSuggestFollowups = false - publishUnlockedToolTiers() yield 'STEP' return } @@ -1231,9 +1164,9 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} } } - // Publish the current unlocked tool tiers before the LLM step so the - // runtime surfaces CORE + the tiers relevant to the active phase. - publishUnlockedToolTiers() + // No per-step tier bookkeeping: progressiveToolDisclosure is pinned + // false (see packages/agent-runtime/src/util/base2-tool-tiers.ts), so + // the runtime always offers the full mode-resolved surface. const stepResult = yield 'STEP' const { stepsComplete, hitStepCap } = stepResult as { stepsComplete: boolean @@ -4896,63 +4829,6 @@ ${disclose(specialistRoutingSection, specialistRoutingPointer)} activeWorkState.lastPinnedStateMessage = '' } - // Progressive tool disclosure (M1-T3/T4/T5): publish the tool tiers - // unlocked for the upcoming LLM step onto mutableAgentState so the - // runtime can narrow the effective tool surface to CORE + these tiers. - // Called live before each STEP. Self-contained inline logic (no - // module-scope imports) because handleSteps is serialized via - // .toString() + new Function(...): the deriveIntentSignals / - // resolveUnlockedTiersForPhase helpers from tool-tiers.ts are inlined - // here. The canary flag is read from programmaticConfig (plain JSON), - // never a module-scope closure. RF-4: the pure helper - // `getPublishUnlockedToolTiers` exported above mirrors this logic for - // direct unit testing without readFileSync+Transpiler brittleness. - function publishUnlockedToolTiers(): void { - if (config?.progressiveToolDisclosure !== true) { - if ( - Array.isArray(mutableAgentState.unlockedToolTiers) && - mutableAgentState.unlockedToolTiers.length > 0 - ) { - delete mutableAgentState.unlockedToolTiers - } - return - } - const phase = String(activeWorkState.currentPhase ?? 'idle') - const promptText = typeof prompt === 'string' ? prompt : '' - const pendingGateFileCount = Array.isArray( - activeWorkState.pendingGateFiles, - ) - ? activeWorkState.pendingGateFiles.length - : 0 - const hasOpenReviewerBlockers = - Array.isArray(activeWorkState.openReviewerBlockers) && - activeWorkState.openReviewerBlockers.length > 0 - const implementIntent = - phase === 'awaiting_validation' || - phase === 'repair_loop' || - phase === 'awaiting_review' || - phase === 'blocked' || - pendingGateFileCount > 0 || - hasOpenReviewerBlockers || - /\b(?:implement|fix|refactor|update|create|add)\b/i.test(promptText) - const auditIntent = - /\b(?:audit|coverage|completeness|review[- ]across|systematic)\b/i.test( - promptText, - ) || phase === 'awaiting_review' - const mediaIntent = - /\.(?:png|jpe?g|webp|gif|blend|obj|gltf|glb)\b/i.test(promptText) - const jobIntent = - /\b(?:(?:background|bg)\s+(?:job|agent|process|task|basher)|kill(?:_|\s+)(?:the\s+)?(?:job|process)|(?:list|check|kill)_jobs?|job(?:Id|\s*id)|tail\s+-f|watch\s+(?:the\s+)?(?:build|logs?|job|process)|long[- ]running\s+(?:dev\s+)?server|dev\s+server)\b/i.test( - promptText, - ) - const tiers: ToolTier[] = [] - if (implementIntent) tiers.push('implement') - if (auditIntent) tiers.push('audit') - if (mediaIntent) tiers.push('media_3d') - if (jobIntent) tiers.push('job_extra') - mutableAgentState.unlockedToolTiers = tiers - } - // Durable one-line mid-turn gate-progress note. Rendered by // buildPinnedActiveWorkMessage as a "Gate progress:" line inside the // existing pinned active-work message — no new yield/add_message is @@ -8877,54 +8753,5 @@ function buildPlanOnlyStepPrompt({}: {}) { ).join('\n') } -/** - * Pure helper mirroring the serialized `publishUnlockedToolTiers` intent logic - * inside `handleSteps`. Exported for direct unit testing (RF-4) so the test - * suite does not need to `readFileSync` + `Bun.Transpiler` + `new Function` - * the generator source — a brittle, formatting-sensitive extraction. The - * handleSteps inline copy and this helper must stay in sync; the RF-3/RF-4 - * sync guard asserts `getPublishUnlockedToolTiers(...) === - * resolveUnlockedTiersForPhase(deriveIntentSignals(...))` across a matrix. - */ -export function getPublishUnlockedToolTiers(params: { - phase: string - pendingGateFileCount: number - hasOpenReviewerBlockers: boolean - lastUserPrompt?: string -}): ToolTier[] { - return resolveUnlockedTiersForPhase(deriveIntentSignals(params)) -} - -/** - * Canary-aware wrapper mirroring the full `publishUnlockedToolTiers` decision: - * when `progressiveToolDisclosure` is off, any stale non-empty unlock list is - * cleared (returns `undefined` to signal deletion). When on, delegates to - * `getPublishUnlockedToolTiers`. Pure and side-effect free for testing. - */ -export function getPublishUnlockedToolTiersWithCanary(params: { - phase: string - pendingGateFileCount: number - hasOpenReviewerBlockers: boolean - lastUserPrompt?: string - progressiveToolDisclosure?: boolean - initialUnlockedToolTiers?: ToolTier[] -}): ToolTier[] | undefined { - if (params.progressiveToolDisclosure !== true) { - if ( - Array.isArray(params.initialUnlockedToolTiers) && - params.initialUnlockedToolTiers.length > 0 - ) { - return undefined - } - return undefined - } - return getPublishUnlockedToolTiers(params) -} - -// Backwards-compatible alias: previous name was tool-specific, now generic. -// Re-exported from base2 for callers that imported via base2; the canonical -// export remains `isEnvFlagEnabled` (aliased from tool-tiers). -export { isEnvFlagEnabled } - const definition = { ...createBase2('default'), id: 'base2' } export default definition diff --git a/agents/base2/tool-tiers.ts b/agents/base2/tool-tiers.ts index 46025a1ee8..a1cb8de92c 100644 --- a/agents/base2/tool-tiers.ts +++ b/agents/base2/tool-tiers.ts @@ -1,201 +1,78 @@ -import { buildArray } from '@codebuff/common/util/array' - import { BASE2_CORE_TOOL_NAMES, BASE2_TIER_TOOL_NAMES, + type UnlockedToolTier, } from '@codebuff/agent-runtime/util/base2-tool-tiers' -import type { ToolTier } from '@codebuff/agent-runtime/util/base2-tool-tiers' import type { AllToolNames } from '../types/secret-agent-definition' -export type { ToolTier } - /** - * Tier tool membership is owned by the runtime mirror in - * `packages/agent-runtime/src/util/base2-tool-tiers.ts` - * (`BASE2_CORE_TOOL_NAMES` / `BASE2_TIER_TOOL_NAMES`), because `agent-runtime` - * must not import from `agents/` (wrong dependency direction). `agents` is the - * correct direction, so these constants re-export the runtime truth instead of - * duplicating it. Re-exporting (rather than copying) makes the two lists - * identical BY CONSTRUCTION, so a one-sided edit to either list now fails at - * compile time (missing/mismatched re-export) instead of only failing the - * progressive-disclosure test suite at runtime. + * Tier membership and the progressive tool-disclosure contract are owned by + * packages/agent-runtime/src/util/base2-tool-tiers.ts; this module only + * resolves the template's mode-gated surface from them. */ -/** Base CORE names without mode conditionals — gates live in resolveModelToolNames. */ -export const CORE_TOOLS: readonly AllToolNames[] = - BASE2_CORE_TOOL_NAMES satisfies readonly AllToolNames[] - -/** Base IMPLEMENT names without mode conditionals. */ -export const IMPLEMENT_TOOLS: readonly AllToolNames[] = - BASE2_TIER_TOOL_NAMES.implement satisfies readonly AllToolNames[] - -/** Base AUDIT names without mode conditionals. */ -export const AUDIT_TOOLS: readonly AllToolNames[] = - BASE2_TIER_TOOL_NAMES.audit satisfies readonly AllToolNames[] - -/** Base MEDIA_3D names without mode conditionals. */ -export const MEDIA_3D_TOOLS: readonly AllToolNames[] = - BASE2_TIER_TOOL_NAMES.media_3d satisfies readonly AllToolNames[] - -/** Base JOB_EXTRA names without mode conditionals. */ -export const JOB_EXTRA_TOOLS: readonly AllToolNames[] = - BASE2_TIER_TOOL_NAMES.job_extra satisfies readonly AllToolNames[] - -// Reflect runtime mirror tier-key safety: BASE2_TIER_TOOL_NAMES is Record, readonly string[]>. -const _base2TierKeySafetyCheck = BASE2_TIER_TOOL_NAMES satisfies Record< - Exclude, - readonly string[] -> -void _base2TierKeySafetyCheck - -/** Canary-on starts core-only until handleSteps unlocks further tiers. */ -export const DEFAULT_UNLOCKED_TIERS_WHEN_PROGRESSIVE: readonly ToolTier[] = [] - -/** Intent signals resolved from the current step context. */ -export type ToolTierIntentSignals = { - implementIntent: boolean - auditIntent: boolean - mediaIntent: boolean - jobIntent: boolean -} +/** Alias of the runtime tier type so one contract keeps one name. */ +export type { UnlockedToolTier } /** - * Deterministically map base2 intent signals to the tool tiers they unlock. - * CORE is always available and is therefore never returned here. Pure and - * side-effect free. Tier decisions depend only on the four intent booleans - * (phase is folded into the signals by `deriveIntentSignals`), so `phase` - * is deliberately not a parameter here. + * Canonical non-core tier order, and the default `unlockedTiers`. Pinned by + * agents/__tests__/base2-progressive-tool-disclosure.test.ts. */ -export function resolveUnlockedTiersForPhase(params: { - /** True when the current step involves editing/planning/validation. */ - implementIntent: boolean - /** True when broad audit/coverage intent is detected. */ - auditIntent: boolean - /** True when media/3d paths are present. */ - mediaIntent: boolean - /** True when background job management is needed. */ - jobIntent: boolean -}): ToolTier[] { - const tiers: ToolTier[] = [] - if (params.implementIntent) tiers.push('implement') - if (params.auditIntent) tiers.push('audit') - if (params.mediaIntent) tiers.push('media_3d') - if (params.jobIntent) tiers.push('job_extra') - return tiers -} +const NON_CORE_TIERS = Object.keys(BASE2_TIER_TOOL_NAMES) as UnlockedToolTier[] -const IMPLEMENT_PHASES = new Set([ - 'awaiting_validation', - 'repair_loop', - 'awaiting_review', - 'blocked', -]) -const IMPLEMENT_KEYWORD_RE = - /\b(?:implement|fix|refactor|update|create)\b/i -const IMPLEMENT_ADD_RE = /\badd\s+\w+/i -const AUDIT_KEYWORD_RE = - /\b(?:audit|coverage|completeness|review[- ]across|systematic)\b/i -const MEDIA_PATH_RE = - /\.(?:png|jpe?g|webp|gif|blend|obj|gltf|glb)\b/i -// Keep job_extra rare: require job-management phrasing, not bare -// kill/server/logs/watch/tail tokens that appear in ordinary prompts. -// Split large alternation into anchored parts to avoid backtracking and -// false positives; each pattern is tested independently. -const JOB_KEYWORD_RES = [ - /\b(?:background|bg)\s+(?:job|agent|process|task)\b/i, - /\bkill(?:_|\s+)(?:the\s+)?(?:job|process)\b/i, - /\b(?:list|check|kill)_jobs?\b/i, - /\bjob\s*id\b/i, - /\btail\s+-f\b/i, - /\bwatch\s+(?:the\s+)?(?:build|logs?|job|process)\b/i, - /\blong[- ]running\s+(?:dev\s+)?server\b/i, - /\bdev\s+server\b/i, -] as const +type ModeGates = { + isFast: boolean + planOnly: boolean + executePlan: boolean + noAskUser: boolean +} /** - * Derive the intent signals that drive tool-tier unlocks from the current - * active-work phase, gate state, and the last user prompt. Pure and - * side-effect free; no imports from base2 so it can be inlined into the - * serialized handleSteps generator. + * The only tools whose availability depends on mode rather than on tier. + * Mode-gated tools are hardcoded here; see + * agents/__tests__/base2-progressive-tool-disclosure.test.ts. */ -export function deriveIntentSignals(params: { - /** Base2ActiveWorkPhase value as a plain string. */ - phase: string - /** Number of files currently pending the validation/reviewer gate. */ - pendingGateFileCount: number - /** True when any reviewer blockers are open. */ - hasOpenReviewerBlockers: boolean - /** The current user prompt, when available. */ - lastUserPrompt?: string -}): ToolTierIntentSignals { - const prompt = params.lastUserPrompt ?? '' - const pendingGateFileCount = Math.max( - 0, - Math.floor( - typeof params.pendingGateFileCount === 'number' && - Number.isFinite(params.pendingGateFileCount) - ? params.pendingGateFileCount - : 0, - ), - ) - const implementIntent = - IMPLEMENT_PHASES.has(params.phase) || - pendingGateFileCount > 0 || - params.hasOpenReviewerBlockers || - IMPLEMENT_KEYWORD_RE.test(prompt) || - IMPLEMENT_ADD_RE.test(prompt) - const auditIntent = - AUDIT_KEYWORD_RE.test(prompt) || params.phase === 'awaiting_review' - const mediaIntent = MEDIA_PATH_RE.test(prompt) - const jobIntent = JOB_KEYWORD_RES.some((re) => re.test(prompt)) - return { implementIntent, auditIntent, mediaIntent, jobIntent } -} - -/** Generic env flag truthy set (1/true/yes/on). Canonical name. */ -export function isEnvFlagEnabled(raw: string | undefined): boolean { - if (typeof raw !== 'string') return false - const normalized = raw.trim().toLowerCase() - return ( - normalized === '1' || - normalized === 'true' || - normalized === 'yes' || - normalized === 'on' - ) +function modeAllowsTool(name: AllToolNames, gates: ModeGates): boolean { + switch (name) { + case 'ask_user': + return !gates.noAskUser + case 'write_todos': + return !gates.isFast && !gates.planOnly + case 'edit_transaction': + case 'edit_3d_asset': + case 'run_targeted_validation': + return !gates.planOnly + case 'run_terminal_command': + return !gates.planOnly && gates.executePlan + default: + return true + } } -/** Backwards-compatible alias — previously tool-specific name. */ -export const isProgressiveToolDisclosureEnvEnabled = isEnvFlagEnabled - type ResolveModelToolNamesParams = { mode: 'default' | 'fast' planOnly?: boolean executePlan?: boolean noAskUser?: boolean - progressiveToolDisclosure: boolean - /** When progressive on, tiers beyond core that are unlocked. Default []. */ - unlockedTiers?: ToolTier[] -} - -function hasUnlockedTier( - unlocked: ReadonlySet, - tier: Exclude, -): boolean { - return unlocked.has(tier) + /** + * Tiers beyond CORE to expose. Defaults to every non-core tier; pass `[]` + * for a CORE-only surface. This is a set, not an ordering: the emitted list + * follows the canonical tier order. + * + * Reached through createBase2's identically named public option; see + * docs/configuration.md. + */ + unlockedTiers?: UnlockedToolTier[] } /** - * Resolve the model-visible toolNames list for createBase2. - * - * - progressive off: full production surface (order matches today's createBase2). - * - progressive on: CORE plus any unlockedTiers, still applying mode gates. + * Resolve the model-visible toolNames list for createBase2: CORE first, then + * one block per unlocked tier, minus the mode-gated tools. * - * Note: empty `unlockedTiers` here is the static template's core-only start - * surface when progressive is on. That is distinct from the *persisted* - * AgentState.unlockedToolTiers contract, where absent/empty means "leave the - * template surface unchanged" (no progressive re-filter at runtime), and where - * non-empty unlocks are ignored entirely when progressive disclosure is off so - * resume/canary-off cannot permanently shrink a full-surface template. + * The mode gates here are base2's ONLY live surface gate; the runtime tier + * ceiling is dormant because progressiveToolDisclosure is pinned false. See + * packages/agent-runtime/src/util/base2-tool-tiers.ts. */ export function resolveModelToolNames( params: ResolveModelToolNamesParams, @@ -205,103 +82,25 @@ export function resolveModelToolNames( planOnly = false, executePlan = false, noAskUser = false, - progressiveToolDisclosure, - unlockedTiers = DEFAULT_UNLOCKED_TIERS_WHEN_PROGRESSIVE, + unlockedTiers = NON_CORE_TIERS, } = params - const isFast = mode === 'fast' - const canDirectEdit = !planOnly - const canRunTerminal = !planOnly && executePlan - const canWriteTodos = !isFast && !planOnly - - // Full surface must stay byte-stable with pre-M1 createBase2 ordering so - // existing base2 tests keep passing when the canary is off. - if (!progressiveToolDisclosure) { - return buildArray( - 'spawn_agents', - 'query_index', - 'read_files', - 'read_image', - 'inspect_3d_asset', - 'render_3d_preview', - 'read_subtree', - 'read_outline', - 'inspect_codebase_structure', - canWriteTodos && 'write_todos', - 'create_plan', - 'update_plan_status', - canDirectEdit && 'edit_transaction', - canDirectEdit && 'edit_3d_asset', - canRunTerminal && 'run_terminal_command', - 'suggest_followups', - !noAskUser && 'ask_user', - 'skill', - 'list_directory', - 'glob', - 'code_search', - 'check_background_agent', - 'check_job', - 'kill_job', - 'read_logs', - 'list_jobs', - 'inspect_workspace', - 'get_task', - 'get_change_review_bundle', - 'inspect_environment', - 'get_affected_tests', - 'get_build_targets', - !planOnly && 'run_targeted_validation', - 'inspect_feature_completeness', - 'evaluate_audit_coverage', - ) + const gates: ModeGates = { + isFast: mode === 'fast', + planOnly, + executePlan, + noAskUser, } - - const unlocked = new Set(unlockedTiers) - const includeImplement = hasUnlockedTier(unlocked, 'implement') - const includeAudit = hasUnlockedTier(unlocked, 'audit') - const includeMedia3d = hasUnlockedTier(unlocked, 'media_3d') - const includeJobExtra = hasUnlockedTier(unlocked, 'job_extra') - - return buildArray( - // CORE - 'spawn_agents', - 'query_index', - 'read_files', - 'read_outline', - 'read_subtree', - 'list_directory', - 'glob', - 'code_search', - !noAskUser && 'ask_user', - 'skill', - 'suggest_followups', - canWriteTodos && 'write_todos', - 'list_jobs', - 'check_job', - 'check_background_agent', - 'read_logs', - // IMPLEMENT - includeImplement && canDirectEdit && 'edit_transaction', - includeImplement && 'create_plan', - includeImplement && 'update_plan_status', - includeImplement && 'inspect_workspace', - includeImplement && 'inspect_environment', - includeImplement && 'get_affected_tests', - includeImplement && 'get_build_targets', - includeImplement && !planOnly && 'run_targeted_validation', - includeImplement && canRunTerminal && 'run_terminal_command', - // AUDIT - includeAudit && 'inspect_codebase_structure', - includeAudit && 'inspect_feature_completeness', - includeAudit && 'evaluate_audit_coverage', - includeAudit && 'get_change_review_bundle', - includeAudit && 'get_task', - // MEDIA_3D - includeMedia3d && 'read_image', - includeMedia3d && 'inspect_3d_asset', - includeMedia3d && 'render_3d_preview', - includeMedia3d && canDirectEdit && 'edit_3d_asset', - // JOB_EXTRA - includeJobExtra && 'kill_job', - ) + const unlocked = new Set(unlockedTiers) + // Deduped so a name listed in both CORE and a tier surfaces exactly once. + return [ + ...new Set([ + ...BASE2_CORE_TOOL_NAMES, + // Single pass over the canonical tier order: no intermediate filtered + // array, and locked tiers contribute nothing. + ...NON_CORE_TIERS.flatMap((tier) => + unlocked.has(tier) ? BASE2_TIER_TOOL_NAMES[tier] : [], + ), + ]), + ].filter((name) => modeAllowsTool(name, gates)) } diff --git a/agents/context-pruner.ts b/agents/context-pruner.ts index a30df681a1..4af077ed2d 100644 --- a/agents/context-pruner.ts +++ b/agents/context-pruner.ts @@ -36,6 +36,31 @@ const definition: AgentDefinition = { cacheExpiryMs: { type: 'number', }, + semanticBudget: { + type: 'object', + properties: { + triggerBudgetTokens: { + type: 'number', + }, + targetBudgetTokens: { + type: 'number', + }, + }, + }, + taskMemory: { + type: 'object', + }, + workspaceState: { + type: 'object', + properties: { + revision: { + type: 'number', + }, + snapshotId: { + type: 'string', + }, + }, + }, }, required: [], }, @@ -669,8 +694,18 @@ const definition: AgentDefinition = { // causes rapid cache refill (the "cache fills up fast" symptom). // The provider simply re-writes the cache, which is cheaper than // regenerating a summary blob. + const estimatedContextTokens = Math.ceil( + currentMessages.reduce( + (total, message) => total + getTextContent(message).length, + 0, + ) / CHARS_PER_TOKEN, + ) + const contextTokenCount = Math.max( + agentState.contextTokenCount ?? 0, + estimatedContextTokens, + ) if ( - agentState.contextTokenCount + TOKEN_COUNT_FUDGE_FACTOR <= + contextTokenCount + TOKEN_COUNT_FUDGE_FACTOR <= maxContextLength ) { yield { @@ -1515,9 +1550,7 @@ const definition: AgentDefinition = { if (km.editsMade.length > KNOWLEDGE_MEMORY_MAX_EDITS) { km.editsMade = km.editsMade.slice(-KNOWLEDGE_MEMORY_MAX_EDITS) } - if ( - km.validationResults.length > KNOWLEDGE_MEMORY_MAX_VALIDATION_RESULTS - ) { + if (km.validationResults.length > KNOWLEDGE_MEMORY_MAX_VALIDATION_RESULTS) { km.validationResults = km.validationResults.slice( -KNOWLEDGE_MEMORY_MAX_VALIDATION_RESULTS, ) @@ -1527,9 +1560,7 @@ const definition: AgentDefinition = { -KNOWLEDGE_MEMORY_MAX_REVIEW_RECEIPTS, ) } - if ( - km.postEditAnchors.length > KNOWLEDGE_MEMORY_MAX_POST_EDIT_ANCHORS - ) { + if (km.postEditAnchors.length > KNOWLEDGE_MEMORY_MAX_POST_EDIT_ANCHORS) { km.postEditAnchors = km.postEditAnchors.slice( -KNOWLEDGE_MEMORY_MAX_POST_EDIT_ANCHORS, ) @@ -1537,10 +1568,10 @@ const definition: AgentDefinition = { if (km.blockers.length > KNOWLEDGE_MEMORY_MAX_BLOCKERS) { km.blockers = km.blockers.slice(-KNOWLEDGE_MEMORY_MAX_BLOCKERS) } - // Per-entry length caps - const capEntry = (entry: string, max: number): string => { - return capTextPreservingEnds(entry, max) - } + + const capEntry = (entry: string, max: number): string => + capTextPreservingEnds(entry, max) + km.decisions = km.decisions.map((e) => capEntry(e, KNOWLEDGE_MEMORY_ENTRY_CHARS), ) @@ -1563,38 +1594,139 @@ const definition: AgentDefinition = { /** Detect the latest substantive user request or override. */ function extractGoalFromMessages(): string { - const candidates = [ - ...(latestLiveUserPromptMessage ? [latestLiveUserPromptMessage] : []), - ...messagesToSummarize, - ].filter((message) => message.role === 'user') - const taggedCandidates = candidates - .filter((message) => message.tags?.includes('USER_PROMPT')) - .reverse() - const taggedSet = new Set(taggedCandidates) - const ordered = [ - ...taggedCandidates, - ...[...candidates] - .reverse() - .filter((message) => !taggedSet.has(message)), - ] - for (const message of ordered) { - const text = sanitizeOperationalStateText(getTextContent(message)) - .replace(/^([\s\S]*?)<\/user_message>$/i, '$1') + function sanitizeUserGoalText(message: Message): string { + // Unwrap SDK wrappers before any tag-based skip so + // combined live-prompt+params history is not treated as empty XML. + let text = sanitizeOperationalStateText(getTextContent(message)) + .replace(/([\s\S]*?)<\/user_message>/gi, '$1') .trim() - if (!text) continue + if (!text) return '' + + // SDK buildUserMessageContent concatenates the live prompt with + // pruner params JSON inside one user_message. Strip only a trailing + // object that contains known pruner param keys. + const trailingJson = text.match(/(\{[\s\S]*\})\s*$/) + if (trailingJson) { + try { + const parsed = JSON.parse(trailingJson[1]) + const prunerParamKeys = [ + 'maxContextLength', + 'assistantToolBudget', + 'userBudget', + 'toolFactsBudget', + 'cacheExpiryMs', + 'taskMemory', + ] + if ( + parsed !== null && + typeof parsed === 'object' && + !Array.isArray(parsed) && + Object.keys(parsed).some((key) => prunerParamKeys.includes(key)) + ) { + text = text + .slice(0, text.length - trailingJson[0].length) + .trim() + } + } catch { + // Keep the original text when the trailing blob is not valid JSON. + } + } + + if (!text) return '' // Skip tool-result-style user messages and system tags - if (text.startsWith('[USER]')) continue - if (text.startsWith('<')) continue - if (text === CONTINUATION_PROMPT_TEXT) continue - if (/^(?:Reviewer|Verification|Harness) gate:/i.test(text)) continue - const truncated = truncateLongText( - text, - KNOWLEDGE_MEMORY_MAX_GOAL_CHARS, - ) - return truncated + if (text.startsWith('[USER]')) return '' + if (text.startsWith('<')) return '' + if (text === CONTINUATION_PROMPT_TEXT) return '' + if (/^(?:Reviewer|Verification|Harness) gate:/i.test(text)) return '' + // Ephemeral live prompts must not replace the task goal. + if (/^Say\s+["'].+["']\s+and nothing else\.?$/i.test(text)) return '' + if (/^(OK|DONE|ACK|CAL|Continue\.?)$/i.test(text)) return '' + // e2e makeLargeContent rounds are filler, not the task goal. + if (/^Round\s+\d+:/i.test(text)) return '' + return truncateLongText(text, KNOWLEDGE_MEMORY_MAX_GOAL_CHARS) .replace(/\[\.\.\.truncated \d+ chars\.\.\.\]\n*/g, ' ') .trim() } + + function isFilePickerDiscovery(message: Message): boolean { + if (message.role === 'assistant' && Array.isArray(message.content)) { + for (const part of message.content) { + if (part.type !== 'tool-call') continue + if (String(part.toolName) !== 'spawn_agents') continue + const input = + part.input && typeof part.input === 'object' + ? (part.input as Record) + : {} + const agents = input.agents + if ( + Array.isArray(agents) && + agents.some( + (agent) => + Boolean(agent) && + typeof agent === 'object' && + (agent as Record).agent_type === + 'file-picker', + ) + ) { + return true + } + } + } + if (message.role !== 'tool') return false + const toolMessage = message as ToolMessage + if (toolMessage.toolName !== 'spawn_agents') return false + if (!Array.isArray(toolMessage.content)) return false + for (const part of toolMessage.content) { + if (part.type !== 'json' || !Array.isArray(part.value)) continue + if ( + part.value.some( + (result) => + Boolean(result) && + typeof result === 'object' && + (result as { agentType?: string }).agentType === 'file-picker', + ) + ) { + return true + } + } + return false + } + + const promptCandidates = [ + ...(latestLiveUserPromptMessage ? [latestLiveUserPromptMessage] : []), + ...messagesToSummarize, + ].filter((message) => message.role === 'user') + + // 1. Newest tagged USER_PROMPT that is not empty/ephemeral. + for (let index = promptCandidates.length - 1; index >= 0; index--) { + const message = promptCandidates[index] + if (!message.tags?.includes('USER_PROMPT')) continue + const goal = sanitizeUserGoalText(message) + if (goal) return goal + } + + // 2. User request immediately preceding a file-picker discovery. + let lastSanitizedUserText = '' + let discoveryLinkedUserText = '' + for (const message of messagesToSummarize) { + if (message.role === 'user') { + const text = sanitizeUserGoalText(message) + if (text) lastSanitizedUserText = text + continue + } + if (isFilePickerDiscovery(message) && lastSanitizedUserText) { + discoveryLinkedUserText = lastSanitizedUserText + } + } + if (discoveryLinkedUserText) return discoveryLinkedUserText + + // 3. Newest remaining untagged user message. + for (let index = promptCandidates.length - 1; index >= 0; index--) { + const message = promptCandidates[index] + if (message.tags?.includes('USER_PROMPT')) continue + const goal = sanitizeUserGoalText(message) + if (goal) return goal + } return '' } diff --git a/agents/e2e/context-pruner.e2e.test.ts b/agents/e2e/context-pruner.e2e.test.ts index e9650d044d..b0da5ca3cc 100644 --- a/agents/e2e/context-pruner.e2e.test.ts +++ b/agents/e2e/context-pruner.e2e.test.ts @@ -81,23 +81,20 @@ describe('Context Pruner Agent Integration', () => { displayName: 'Context Pruner Test Agent', model: 'anthropic/claude-haiku-4.5', includeMessageHistory: true, - toolNames: ['spawn_agents'], + toolNames: ['spawn_agent_inline'], spawnableAgents: ['context-pruner'], handleSteps: function* () { - // Spawn context-pruner with a lower token limit to force pruning + // spawn_agent_inline copies pruned history back onto the parent. yield { - toolName: 'spawn_agents', + toolName: 'spawn_agent_inline', input: { - agents: [ - { - agent_type: 'context-pruner', - params: { - maxContextLength: 50000, // Low limit to force pruning - }, - }, - ], + agent_type: 'context-pruner', + params: { + maxContextLength: 1_500, // Low limit to force pruning + }, }, - } + includeToolCall: false, + } as any yield { type: 'STEP_TEXT', text: 'PRUNING_COMPLETE' } }, } @@ -210,22 +207,19 @@ describe('Context Pruner Agent Integration', () => { displayName: 'Aggressive Prune Test Agent', model: 'anthropic/claude-haiku-4.5', includeMessageHistory: true, - toolNames: ['spawn_agents'], + toolNames: ['spawn_agent_inline'], spawnableAgents: ['context-pruner'], handleSteps: function* () { yield { - toolName: 'spawn_agents', + toolName: 'spawn_agent_inline', input: { - agents: [ - { - agent_type: 'context-pruner', - params: { - maxContextLength: 10000, // Very low limit to force aggressive pruning - }, - }, - ], + agent_type: 'context-pruner', + params: { + maxContextLength: 400, // Very low limit to force aggressive pruning + }, }, - } + includeToolCall: false, + } as any yield { type: 'STEP_TEXT', text: 'DONE' } }, } diff --git a/agents/e2e/context-pruning-threshold.e2e.test.ts b/agents/e2e/context-pruning-threshold.e2e.test.ts index da0fb7e7a3..5c7930177f 100644 --- a/agents/e2e/context-pruning-threshold.e2e.test.ts +++ b/agents/e2e/context-pruning-threshold.e2e.test.ts @@ -2,8 +2,8 @@ * E2E Test: Context Pruning Threshold Verification * * This test verifies that context pruning triggers at the correct token count - * threshold and not prematurely. It uses the real token counting API and - * a multi-turn conversation to accumulate context naturally. + * threshold and not prematurely. It uses setupE2eMocks() for deterministic + * token counting and a multi-turn conversation to accumulate context naturally. * * Background: A previous bug caused the token counting API to either fail * (falling back to a local overcounting formula) or apply a 30% buffer @@ -36,19 +36,17 @@ import { beforeAll, describe, expect, it } from 'bun:test' import { isTextPart, makeLargeContent, - isToolCallPart, - isToolMessageWithId, verifyToolCallPairIntegrity, } from './helpers/pruning-test-helpers' +import { setupE2eMocks } from '../../sdk/e2e/utils/e2e-mocks' + +import contextPruner from '../context-pruner' type SpawnAgentInlineToolInput = { agent_type: string params?: Record prompt?: string } -import { setupE2eMocks } from '../../sdk/e2e/utils/e2e-mocks' - -import contextPruner from '../context-pruner' // Typed wrapper preserves schema-drift detection via `satisfies` — avoids `as unknown` erasure (RF-3 companion). const prunerAgent = contextPruner satisfies AgentDefinition @@ -104,7 +102,7 @@ const testAgent: AgentDefinition = { displayName: 'Context Pruning Threshold Test Agent', model: 'anthropic/claude-haiku-4.5', includeMessageHistory: true, - toolNames: ['spawn_agents'], + toolNames: ['spawn_agent_inline'], spawnableAgents: ['context-pruner'], instructionsPrompt: `You are a test agent for verifying context pruning behavior. When the user asks you to do something, do it briefly and concisely. Just say "OK" or "DONE" as requested.`, handleSteps: function* ({ params }: any) { @@ -114,7 +112,7 @@ const testAgent: AgentDefinition = { input: { agent_type: 'context-pruner', params: (params as Record | undefined) ?? {}, - }, + } satisfies SpawnAgentInlineToolInput, includeToolCall: false, } as any @@ -335,10 +333,10 @@ describe('Context Pruning Threshold E2E', () => { const requiredPath = 'packages/agent-runtime/src/run-agent-step.ts' const requiredConstraint = 'CONSTRAINT_CONTEXT_RECALL: preserve semantic compaction before mechanical trimming.' - // Synthetic history prepended out-of-order to seed recall-invariant — not meant to model natural turn order. - // Order-insensitivity: the pruner must preserve causality (user constraint → file-picker discovery → structured result) - // regardless of synthetic prepend position. The invariant below validates that compaction preserves - // file-picker discovery provenance and that the user constraint remains causally prior to its discovery (RF-5). + // Synthetic history prepended in-order at the front (user, tool-call, tool-result) to seed the recall invariant. + // The pruner must preserve causality (user constraint → file-picker discovery → structured result). + // The invariant below validates that compaction preserves file-picker discovery provenance + // and that the user constraint remains causally prior to its discovery (RF-5). messages.unshift( createMessage( 'user', @@ -456,8 +454,8 @@ describe('Context Pruning Threshold E2E', () => { // 30% buffer: ~90k reported as ~117k → premature pruning in 100k run ✗ // Local fallback: ~90k reported as ~135k+ → premature pruning in 100k run ✗ - // Tightened to ~70k to keep true tokens deterministically <100k even with 1.3x variance. - // Previously 95k *1.3 = 123k could exceed the 100k limit due to token variance, causing + // Tightened to ~70k to keep true tokens deterministically <100k even with 1.4x variance. + // Previously 95k *1.4 = 133k could exceed the 100k limit due to token variance, causing // the no-premature-prune assertion to be conditionally skipped and hiding the 30% buffer bug. const TARGET_ESTIMATED_TOKENS = 70_000 const messages = buildMessageHistory(TARGET_ESTIMATED_TOKENS) @@ -536,14 +534,14 @@ describe('Context Pruning Threshold E2E', () => { // Ratio of true token count to estimated content tokens. // Estimate is for message content only; actual includes system prompt + tool definitions. - // So ratio 1.0-1.3 is expected. A 30% buffer on the full count pushes ratio above 1.3. + // So ratio 1.0-1.4 is expected. A 30% buffer on the full count pushes ratio well above 1.4. const ratio = trueTokenCount / TARGET_ESTIMATED_TOKENS // Deterministic ratio bounds — fail fast if token counting is over-reporting (30% buffer or fallback) expect(ratio).toBeGreaterThan(0.8) - expect(ratio).toBeLessThan(1.3) + expect(ratio).toBeLessThan(1.4) // Deterministic no-premature-prune assertion: with TARGET 70k, true tokens must be <100k - // when counting is accurate (70k*1.3=91k <100k). If true tokens exceed the limit, the + // when counting is accurate (70k*1.4=98k <100k). If true tokens exceed the limit, the // test is mis-targeted and should fail rather than silently skip the critical assertion. expect(trueTokenCount).toBeLessThan(MAX_CONTEXT_LENGTH) expect(pruningResult.wasPruned).toBe(false) diff --git a/agents/e2e/file-explorer.e2e.test.ts b/agents/e2e/file-explorer.e2e.test.ts index d278500b51..9b088150c6 100644 --- a/agents/e2e/file-explorer.e2e.test.ts +++ b/agents/e2e/file-explorer.e2e.test.ts @@ -1,4 +1,4 @@ -import { OpenbuffClient, type AgentDefinition } from '@openbuff/sdk' +import { OpenbuffClient, type AgentDefinition, type Message } from '@openbuff/sdk' import { beforeAll, describe, expect, it } from 'bun:test' import { setupE2eMocks } from '../../sdk/e2e/utils/e2e-mocks' @@ -50,6 +50,37 @@ function extractFiles(obj: unknown): unknown[] | undefined { return undefined } +function isTextContentPart( + part: unknown, +): part is { type: 'text'; text: string } { + return ( + !!part && + typeof part === 'object' && + (part as { type?: unknown }).type === 'text' && + typeof (part as { text?: unknown }).text === 'string' + ) +} + +function collectAssistantText(messages: Message[]): string { + const texts: string[] = [] + for (const msg of messages) { + if (msg.role !== 'assistant' || !Array.isArray(msg.content)) continue + for (const part of msg.content) { + if (isTextContentPart(part)) { + texts.push(part.text) + } + } + } + return texts.join('\n') +} + +function splitNewlinePaths(text: string): string[] { + return text + .split(/\r?\n/) + .map((s) => s.trim()) + .filter((s) => s.length > 0) +} + function parseListedPaths(outputStr: string): string[] { try { const parsed = JSON.parse(outputStr) @@ -75,14 +106,22 @@ function parseListedPaths(outputStr: string): string[] { } const direct = tryExtract(parsed) if (direct) return direct + // lastMessage/allMessages hide newline-separated paths as escaped \\n in JSON. + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const envelope = parsed as { type?: unknown; value?: unknown } + if ( + (envelope.type === 'lastMessage' || envelope.type === 'allMessages') && + Array.isArray(envelope.value) + ) { + const assistantText = collectAssistantText(envelope.value as Message[]) + if (assistantText.length > 0) return splitNewlinePaths(assistantText) + } + } } catch { // fall through to line-based fallback } // Fallback: split only on newlines to avoid mis-splitting paths that contain commas. - return outputStr - .split(/\r?\n/) - .map((s) => s.trim()) - .filter((s) => s.length > 0) + return splitNewlinePaths(outputStr) } /** diff --git a/agents/file-explorer/file-lister.ts b/agents/file-explorer/file-lister.ts index 1dfdfb646b..f399c5d102 100644 --- a/agents/file-explorer/file-lister.ts +++ b/agents/file-explorer/file-lister.ts @@ -26,7 +26,7 @@ export const createFileLister = (): Omit => ({ type: 'array' as const, items: { type: 'string' as const }, description: - 'Optional project-relative directories to search. Absolute paths, traversal, glob syntax, and more than 8 entries are rejected.', + 'Optional project-relative directories to search. Absolute paths, traversal, and glob syntax are rejected. At most 8 valid directories are used; extra entries are ignored.', }, }, required: [], @@ -47,18 +47,14 @@ export const createFileLister = (): Omit => ({ Here's an example response with made up file paths (these are not real file paths, just an example): -packages/core/src/index.ts -packages/core/src/api/server.ts -packages/core/src/api/routes/user.ts -packages/core/src/utils/logger.ts -packages/common/src/util/stringify.ts -packages/common/src/types/user.ts -packages/common/src/constants/index.ts -packages/utils/src/cli/parseArgs.ts -docs/routes/index.md -docs/routes/user.md -package.json -README.md +example/src/widget.ts +example/src/gadget.ts +example/lib/factory.ts +example/lib/types/widget.ts +example/tests/widget.test.ts +docs/example/overview.md +docs/example/api.md +config/example.json Again: Do not call any tools or write anything else other than the chosen file paths on new lines. Go. @@ -67,57 +63,152 @@ Again: Do not call any tools or write anything else other than the chosen file p handleSteps: function* ({ prompt, params }) { // Keep helpers inside handleSteps: bundled programmatic agents serialize // this generator without its module-level closures. + const extractFilePathsFromPrintedTree = (printedTree: string): string[] => { + const lines = printedTree + .split('\n') + .filter((rawLine) => rawLine.trim().length > 0) + const indentSizes = lines + .map((rawLine) => rawLine.length - rawLine.trimStart().length) + .filter((indent) => indent > 0) + const indentWidth = Math.max( + 1, + indentSizes.reduce((width, indent) => { + let a = width + let b = indent + while (b !== 0) { + const next = a % b + a = b + b = next + } + return a + }, indentSizes[0] ?? 1), + ) + + const paths: string[] = [] + const directoryStack: string[] = [] + let previousFileDepth: number | undefined + + for (const rawLine of lines) { + const leading = rawLine.length - rawLine.trimStart().length + const depth = Math.floor(leading / indentWidth) + const name = rawLine.trim().replace(/\s+\d+\s*$/, '') + + // Parsed symbols are printed one indentation level below their file. + if (previousFileDepth !== undefined && depth > previousFileDepth) { + continue + } + previousFileDepth = undefined + + if (name.endsWith('/')) { + directoryStack.length = depth + directoryStack[depth] = name.slice(0, -1) + continue + } + + const path = [...directoryStack.slice(0, depth), name] + .filter(Boolean) + .join('/') + if (path.length > 0) paths.push(path) + previousFileDepth = depth + } + + if (paths.length > 0) { + return Array.from(new Set(paths)) + } + + const fallbackPaths: string[] = [] + const filePathPattern = + /(?:^|[^A-Za-z0-9_./-])((?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+\.(?:ts|tsx|js|jsx|json|md))\b/g + let match: RegExpExecArray | null + while ((match = filePathPattern.exec(printedTree)) !== null) { + fallbackPaths.push(match[1]) + } + return Array.from(new Set(fallbackPaths)) + } const extractFilePathsFromSubtree = ( toolResult: ToolResultOutput[] | undefined, ): string[] => { - const directoryResults = (toolResult ?? []).flatMap((part) => { + const subtreeEntries = (toolResult ?? []).flatMap((part) => { if (part.type !== 'json' || !Array.isArray(part.value)) return [] return part.value.filter( - (value): value is { - path: string - type: 'directory' - printedTree: string + ( + value, + ): value is { + path?: string + type?: string + printedTree?: string } => typeof value === 'object' && value !== null && - !Array.isArray(value) && - value.type === 'directory' && - typeof value.path === 'string' && - typeof value.printedTree === 'string', + !Array.isArray(value), ) }) const paths: string[] = [] - for (const result of directoryResults) { - const directoryStack: string[] = [] - let previousFileDepth: number | undefined + for (const entry of subtreeEntries) { + if ( + entry.type === 'file' && + typeof entry.path === 'string' && + entry.path.length > 0 + ) { + paths.push(entry.path.replace(/\\/g, '/').replace(/^\.\//, '')) + continue + } + if ( + entry.type === 'directory' && + typeof entry.printedTree === 'string' + ) { + paths.push(...extractFilePathsFromPrintedTree(entry.printedTree)) + } + } - for (const rawLine of result.printedTree.split('\n')) { - if (rawLine.trim().length === 0) continue - const depth = rawLine.length - rawLine.trimStart().length - const name = rawLine.trim() + return Array.from(new Set(paths)) + } + const extractFilePathsFromQueryIndex = ( + toolResult: ToolResultOutput[] | undefined, + ): string[] => { + const paths: string[] = [] + + for (const part of toolResult ?? []) { + if (part.type !== 'json') continue + const value = part.value + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + continue + } + if (!('results' in value) || !Array.isArray(value.results)) { + continue + } - // Parsed symbols are printed one indentation level below their file. - if (previousFileDepth !== undefined && depth > previousFileDepth) { + for (const item of value.results) { + if (typeof item !== 'object' || item === null || Array.isArray(item)) { continue } - previousFileDepth = undefined - - if (name.endsWith('/')) { - directoryStack.length = depth - directoryStack[depth] = name.slice(0, -1) + if ( + 'path' in item && + typeof item.path === 'string' && + item.path.length > 0 + ) { + paths.push(item.path) + } + if (!('relatedFiles' in item) || !Array.isArray(item.relatedFiles)) { continue } - - const path = [...directoryStack.slice(0, depth), name] - .filter(Boolean) - .join('/') - if (path.length > 0) paths.push(path) - previousFileDepth = depth + for (const related of item.relatedFiles) { + if ( + typeof related === 'object' && + related !== null && + !Array.isArray(related) && + 'path' in related && + typeof related.path === 'string' && + related.path.length > 0 + ) { + paths.push(related.path) + } + } } } - return Array.from(new Set(paths)) + return paths } const isWithinDirectory = (path: string): boolean => directories.some( @@ -173,8 +264,9 @@ Again: Do not call any tools or write anything else other than the chosen file p directories.length > 0 ? `${prompt ?? ''}\nOnly return files within: ${directories.join(', ')}` : prompt + let indexResult: ToolResultOutput[] | undefined if (typeof prompt === 'string' && prompt.trim().length > 0) { - yield { + const { toolResult } = yield { toolName: 'query_index', input: { query: scopedPrompt, @@ -182,6 +274,7 @@ Again: Do not call any tools or write anything else other than the chosen file p ...(directories.length > 0 ? { pathPrefixes: directories } : {}), }, } + indexResult = toolResult } const { toolResult: subtreeResult } = yield { toolName: 'read_subtree', @@ -191,18 +284,23 @@ Again: Do not call any tools or write anything else other than the chosen file p }, } - if (directories.length > 0) { - const scopedPaths = extractFilePathsFromSubtree(subtreeResult).filter( - isWithinDirectory, - ) - const rankedPaths = rankFilePaths(scopedPaths) - if (rankedPaths.length > 0) { - yield { - type: 'STEP_TEXT', - text: rankedPaths.join('\n'), - } satisfies StepText - return - } + const candidatePaths = Array.from( + new Set([ + ...extractFilePathsFromQueryIndex(indexResult), + ...extractFilePathsFromSubtree(subtreeResult), + ]), + ) + const scopedPaths = + directories.length > 0 + ? candidatePaths.filter(isWithinDirectory) + : candidatePaths + const rankedPaths = rankFilePaths(scopedPaths) + if (rankedPaths.length > 0) { + yield { + type: 'STEP_TEXT', + text: rankedPaths.join('\n'), + } satisfies StepText + return } yield 'STEP' diff --git a/agents/file-explorer/file-picker.ts b/agents/file-explorer/file-picker.ts index 5ed365d2b0..4a545c3e10 100644 --- a/agents/file-explorer/file-picker.ts +++ b/agents/file-explorer/file-picker.ts @@ -30,7 +30,7 @@ export const createFilePicker = (): Omit => { type: 'array' as const, items: { type: 'string' as const }, description: - 'Optional list of paths to directories to look within. If omitted, the entire project tree is used.', + 'Optional list of project-relative directories to look within. Absolute paths, traversal, and glob syntax are rejected rather than rewritten. If omitted, the entire project tree is used.', }, }, required: [], @@ -56,7 +56,6 @@ export const createFilePicker = (): Omit => { }, includeMessageHistory: false, toolNames: ['spawn_agents', 'set_output'], - programmaticToolNames: ['read_files'], spawnableAgents: ['file-lister'], systemPrompt: `You are an expert at finding relevant files in a codebase. ${PLACEHOLDER.FILE_TREE_PROMPT}`, @@ -71,66 +70,6 @@ Do not use any other tools or spawn any further agents. } } -/** - * Extract the raw spawn_agents results from the toolResult wrapper. - * The spawn_agents tool returns results as [{type: 'json', value: [...]}]. - * This extracts the inner value from each spawned agent result. - */ -function extractSpawnResults(results: any[] | undefined): any[] { - if (!results || results.length === 0) return [] - const jsonResult = results.find((r) => r.type === 'json') - if (!jsonResult?.value) return [] - const spawnedResults = Array.isArray(jsonResult.value) - ? jsonResult.value - : [jsonResult.value] - return spawnedResults.map((result: any) => result?.value).filter(Boolean) -} - -/** - * Extract text content from a spawned agent's output, handling multiple - * output formats that the agent runtime may produce: - * - lastMessage / allMessages: traverses message array for assistant text - * - structuredOutput: extracts string value or text-containing fields - * - Direct strings: raw string output - */ -function extractAgentText(agentOutput: any): string | null { - if (!agentOutput) return null - - // Direct string value - if (typeof agentOutput === 'string') return agentOutput - - // lastMessage / allMessages format — traverse messages for assistant text - if ( - (agentOutput.type === 'lastMessage' || - agentOutput.type === 'allMessages') && - Array.isArray(agentOutput.value) - ) { - for (let i = agentOutput.value.length - 1; i >= 0; i--) { - const message = agentOutput.value[i] - if (message.role === 'assistant' && Array.isArray(message.content)) { - for (const part of message.content) { - if (part.type === 'text' && typeof part.text === 'string') { - return part.text - } - } - } - } - } - - // structuredOutput format — value may be a string or object with text fields - if (agentOutput.type === 'structuredOutput') { - if (typeof agentOutput.value === 'string') return agentOutput.value - if (isObject(agentOutput.value)) { - for (const key of ['message', 'text', 'content', 'output', 'response']) { - const val = agentOutput.value[key] - if (typeof val === 'string' && val) return val - } - } - } - - return null -} - function extractErrorMessage(agentOutput: any): string | null { if (!agentOutput) return null if (agentOutput.type === 'error') { @@ -139,10 +78,6 @@ function extractErrorMessage(agentOutput: any): string | null { return null } -function isObject(value: any): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value) -} - const handleSteps: SecretAgentDefinition['handleSteps'] = function* ({ prompt, params, @@ -275,17 +210,16 @@ const handleSteps: SecretAgentDefinition['handleSteps'] = function* ({ } // C1.9: Lexical project-root containment for paths returned by spawned // file-lister agents. Rejects path traversal (..) and absolute paths outside - // the project root before handing paths to read_files. Defense in depth on - // top of the SDK's read_files backend validation. Uses process.cwd() since - // handleSteps is serialized and cannot import helpers. + // the project root before emitting structured output. Defense in depth. + // Uses process.cwd() since handleSteps is serialized and cannot import helpers. const isSafeProjectPath = (rawPath: string): boolean => { if (typeof rawPath !== 'string' || rawPath.length === 0) return false const trimmed = rawPath.trim() if (trimmed.length === 0) return false - // Reject any path containing parent-directory traversal. - if (trimmed.includes('..')) return false - // Reject Windows-style traversal too. - if (trimmed.includes('\\..') || trimmed.includes('..\\')) return false + // Reject parent-directory segments after normalize/split, not substring `..` + // (so names like foo..bar.ts stay valid). + const normalizedSegments = trimmed.replace(/\\/g, '/').split('/') + if (normalizedSegments.some((segment) => segment === '..')) return false // Absolute paths are only allowed if they're inside the project root. if (trimmed.startsWith('/') || /^[A-Za-z]:[\\/]/.test(trimmed)) { const cwd = typeof process.cwd === 'function' ? process.cwd() : '' @@ -318,23 +252,42 @@ const handleSteps: SecretAgentDefinition['handleSteps'] = function* ({ errorText, debugMessage, } = processSpawnResults(spawnResults) - // Filter out unsafe paths before read_files (C1.9). + // Filter out unsafe paths before emitting output (C1.9). const paths = rawPaths.filter(isSafeProjectPath) - const requestedDirectories = Array.isArray(params?.directories) + const rawRequestedDirectories = Array.isArray(params?.directories) ? params.directories + : [] + // Match file-lister: reject absolute, traversal, and glob entries. Do not + // strip leading slashes (“/etc” must not become in-scope “etc”). + const requestedDirectories = Array.from( + new Set( + rawRequestedDirectories .filter((value): value is string => typeof value === 'string') .map((value) => value.replace(/\\/g, '/').replace(/^\.\//, '')) - .map((value) => value.replace(/^\/+|\/+$/g, '')) - .filter(Boolean) - : [] - const scopedPaths = paths.filter((candidate) => { - if (requestedDirectories.length === 0) return true - const normalized = candidate.replace(/\\/g, '/').replace(/^\.\//, '') - return requestedDirectories.some( - (directory) => - normalized === directory || normalized.startsWith(directory + '/'), - ) - }) + .map((value) => value.replace(/\/+$/, '')) + .filter( + (value) => + value.length > 0 && + value !== '.' && + !value.startsWith('/') && + !/^[A-Za-z]:\//.test(value) && + !value.split('/').includes('..') && + !/[?*{}[\]]/.test(value), + ), + ), + ) + const scopedPaths = + rawRequestedDirectories.length > 0 && requestedDirectories.length === 0 + ? [] + : paths.filter((candidate) => { + if (requestedDirectories.length === 0) return true + const normalized = candidate.replace(/\\/g, '/').replace(/^\.\//, '') + return requestedDirectories.some( + (directory) => + normalized === directory || + normalized.startsWith(directory + '/'), + ) + }) const droppedCount = rawPaths.length - paths.length if (droppedCount > 0) { logger?.debug?.( @@ -388,19 +341,28 @@ const handleSteps: SecretAgentDefinition['handleSteps'] = function* ({ } if (orderedPaths.length === 0) { + const outOfScopeOnly = + paths.length > 0 && rawRequestedDirectories.length > 0 yield { type: 'STEP_TEXT', - text: 'No safe project-relative file paths were returned by file-lister.', + text: outOfScopeOnly + ? 'No file paths were found within the requested directories.' + : 'No safe project-relative file paths were returned by file-lister.', } satisfies StepText return } yield { - toolName: 'read_files', - input: { paths: orderedPaths }, + toolName: 'set_output', + input: { + files: orderedPaths.map((path) => ({ + path, + summary: path.split('/').pop() || path, + })), + }, + includeToolCall: false, } - - yield 'STEP' + return } const definition: SecretAgentDefinition = { @@ -408,5 +370,5 @@ const definition: SecretAgentDefinition = { ...createFilePicker(), } -export { extractSpawnResults, extractAgentText, extractErrorMessage, isObject } +export { extractErrorMessage } export default definition diff --git a/agents/tool-reachability.test.ts b/agents/tool-reachability.test.ts index e90c9a1859..c4a3520199 100644 --- a/agents/tool-reachability.test.ts +++ b/agents/tool-reachability.test.ts @@ -75,11 +75,10 @@ const HARNESS_STATE_TOOLS = ['git_status'] as const describe('agent tool reachability', () => { for (const mode of ['default', 'fast'] as const) { test(`base2 (${mode}) exposes its intended read/mutation surface`, () => { - // Progressive disclosure is ON by default (CORE-only static list). - // Reachability asserts the intended full mutation surface. - const definition = createBase2(mode, { - progressiveToolDisclosure: false, - }) + // Every non-core tier is unlocked by default, so the static list already + // IS the full mode-resolved surface (see + // agents/__tests__/base2-progressive-tool-disclosure.test.ts). + const definition = createBase2(mode) const tools = definition.toolNames ?? [] const programmaticTools = definition.programmaticToolNames ?? [] for (const tool of STRUCTURAL_READ_TOOLS) { @@ -99,11 +98,7 @@ describe('agent tool reachability', () => { } test('execute-plan exposes direct execution without proposal indirection', () => { - const tools = - createBase2('default', { - executePlan: true, - progressiveToolDisclosure: false, - }).toolNames ?? [] + const tools = createBase2('default', { executePlan: true }).toolNames ?? [] expect(tools).toContain('edit_transaction') for (const tool of LEGACY_DIRECT_EDIT_TOOLS) expect(tools).not.toContain(tool) @@ -114,6 +109,8 @@ describe('agent tool reachability', () => { }) test('plan-only excludes project execution and proposal actions', () => { + // Plan mode relies on MODE gates, not tier gates: every non-core tier is + // unlocked by default, so these tools stay absent purely because of planOnly. const tools = createBase2('default', { planOnly: true }).toolNames ?? [] expect(tools).not.toContain('edit_transaction') for (const tool of LEGACY_DIRECT_EDIT_TOOLS) diff --git a/agents/types/tools.ts b/agents/types/tools.ts index e8ec0e801c..1a1590bfa3 100644 --- a/agents/types/tools.ts +++ b/agents/types/tools.ts @@ -251,6 +251,7 @@ export interface EditTransactionParams { occurrenceIndex?: number /** Optional authenticated cap.v3 readCapability copied verbatim from the matching fresh read_files editAnchor. */ basedOnRead?: string + /** 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. */ skipIfMissing?: boolean }[] } @@ -1058,6 +1059,7 @@ export interface StrReplaceParams { occurrenceIndex?: number /** Optional authenticated cap.v3 readCapability copied verbatim from the matching fresh read_files editAnchor. */ basedOnRead?: string + /** 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. */ skipIfMissing?: boolean }[] } diff --git a/cli/src/components/scroll-to-bottom-button.tsx b/cli/src/components/scroll-to-bottom-button.tsx index 7558dfc13a..9c3732b165 100644 --- a/cli/src/components/scroll-to-bottom-button.tsx +++ b/cli/src/components/scroll-to-bottom-button.tsx @@ -2,21 +2,30 @@ import { TextAttributes } from '@opentui/core' import { useState } from 'react' import { Button } from './button' +import { useTerminalLayout } from '../hooks/use-terminal-layout' import { useTheme } from '../hooks/use-theme' interface ScrollToBottomButtonProps { onClick: () => void + /** Keep the glyph-only label even on hover (narrow terminals). */ + compact?: boolean } export const ScrollToBottomButton = ({ onClick, + compact, }: ScrollToBottomButtonProps) => { const theme = useTheme() + const { width } = useTerminalLayout() const [hovered, setHovered] = useState(false) + const isCompact = compact ?? width.atMost('sm') return ( diff --git a/cli/src/components/status-bar.tsx b/cli/src/components/status-bar.tsx index e879a6a076..c3d437336d 100644 --- a/cli/src/components/status-bar.tsx +++ b/cli/src/components/status-bar.tsx @@ -5,12 +5,13 @@ import { Button } from './button' import { ScrollToBottomButton } from './scroll-to-bottom-button' import { ShimmerText } from './shimmer-text' +import { useTerminalLayout } from '../hooks/use-terminal-layout' import { useTheme } from '../hooks/use-theme' -import { formatElapsedTime } from '../utils/format-elapsed-time' +import { formatIndexStatusChip, type IndexStatusPeek } from '../utils/index-status' import { - formatIndexStatusChip, - type IndexStatusPeek, -} from '../utils/index-status' + selectStatusBarChips, + type StatusBarChipTone, +} from '../utils/status-bar-chips' import type { StatusIndicatorState } from '../utils/status-indicator-state' /** A small status-bar action button with hover-bold styling. */ @@ -45,13 +46,20 @@ const StatusActionButton = ({ const SHIMMER_INTERVAL_MS = 160 -const formatTokenCount = (tokens: number): string => { - if (tokens < 1_000) return Math.round(tokens).toString() - if (tokens < 1_000_000) { - const value = tokens / 1_000 - return `${value >= 100 ? Math.round(value) : value.toFixed(1).replace(/\.0$/, '')}k` +const chipForeground = ( + theme: ReturnType, + tone: StatusBarChipTone, +) => { + switch (tone) { + case 'muted': + return theme.muted + case 'secondary': + return theme.secondary + case 'warning': + return theme.warning + case 'error': + return theme.error } - return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, '')}m` } interface StatusBarProps { @@ -84,14 +92,17 @@ export const StatusBar = ({ onStop, }: StatusBarProps) => { const theme = useTheme() + const { width, terminalWidth } = useTerminalLayout() const [elapsedSeconds, setElapsedSeconds] = useState(0) + const kind = statusIndicatorState.kind + const isActive = kind === 'waiting' || kind === 'streaming' + const showStop = Boolean(onStop && isActive) + // Show timer when actively working (streaming or waiting for response) or paused (ask_user) // This uses statusIndicatorState as the single source of truth for "is the LLM working?" const shouldShowTimer = - statusIndicatorState?.kind === 'waiting' || - statusIndicatorState?.kind === 'streaming' || - statusIndicatorState?.kind === 'paused' + kind === 'waiting' || kind === 'streaming' || kind === 'paused' useEffect(() => { if (!timerStartTime || !shouldShowTimer) { @@ -100,7 +111,7 @@ export const StatusBar = ({ } // When paused, don't update the timer - just keep the frozen value - if (statusIndicatorState?.kind === 'paused') { + if (kind === 'paused') { // Calculate current elapsed time once and freeze it const now = Date.now() const elapsed = Math.floor((now - timerStartTime) / 1000) @@ -118,7 +129,21 @@ export const StatusBar = ({ const interval = setInterval(updateElapsed, 1000) return () => clearInterval(interval) - }, [timerStartTime, shouldShowTimer, statusIndicatorState?.kind]) + }, [timerStartTime, shouldShowTimer, kind]) + + const { chips } = selectStatusBarChips({ + widthSize: width.size, + terminalWidth, + contextWindowUsage, + sessionCostCents, + modelName, + diffStats, + indexChip: formatIndexStatusChip(indexStatus ?? null), + elapsedSeconds, + showTimer: shouldShowTimer, + showStop, + isActive, + }) const renderStatusIndicator = () => { switch (statusIndicatorState.kind) { @@ -147,7 +172,11 @@ export const StatusBar = ({ case 'waiting': return ( @@ -156,7 +185,11 @@ export const StatusBar = ({ case 'streaming': return ( @@ -170,102 +203,12 @@ export const StatusBar = ({ } } - const renderElapsedTime = () => { - if (!shouldShowTimer || elapsedSeconds === 0) { - return null - } - - return {formatElapsedTime(elapsedSeconds)} - } - - const renderContextWindowUsage = () => { - if (!contextWindowUsage || contextWindowUsage.max <= 0) { - return null - } - - const pct = Math.round( - (contextWindowUsage.used / contextWindowUsage.max) * 100, - ) - const fg = - pct >= 90 ? theme.error : pct >= 70 ? theme.warning : theme.secondary - - return ( - - {`ctx ${formatTokenCount(contextWindowUsage.used)}/${formatTokenCount(contextWindowUsage.max)} (${pct}%)`} - - ) - } - - const renderSessionCost = () => { - if (sessionCostCents == null || sessionCostCents === 0) { - return null - } - const dollars = sessionCostCents / 100 - const formatted = - dollars < 0.01 - ? `$${(sessionCostCents / 100).toFixed(4)}` - : `$${dollars.toFixed(2)}` - return {`cost ${formatted}`} - } - - const renderModelName = () => { - if (!modelName) { - return null - } - // Shorten common provider prefixes for compactness - const short = modelName.replace( - /^(openai|anthropic|google|openrouter)\//, - '', - ) - return {short} - } - - const renderDiffStats = () => { - if (!diffStats) { - return null - } - const { modified, added, deleted } = diffStats - const total = modified + added + deleted - if (total === 0) { - return null - } - const parts: string[] = [] - if (modified > 0) parts.push(`~${modified}`) - if (added > 0) parts.push(`+${added}`) - if (deleted > 0) parts.push(`-${deleted}`) - return {`git ${parts.join(' ')}`} - } - - const renderIndexStatus = () => { - const chip = formatIndexStatusChip(indexStatus ?? null) - if (!chip) { - return null - } - const fg = - chip.tone === 'error' - ? theme.error - : chip.tone === 'warning' - ? theme.warning - : theme.secondary - return {chip.label} - } - const statusIndicatorContent = renderStatusIndicator() - const elapsedTimeContent = renderElapsedTime() - const contextWindowContent = renderContextWindowUsage() - const sessionCostContent = renderSessionCost() - const modelNameContent = renderModelName() - const diffStatsContent = renderDiffStats() - const indexStatusContent = renderIndexStatus() - const hasContent = - statusIndicatorContent || - elapsedTimeContent || - contextWindowContent || - sessionCostContent || - modelNameContent || - diffStatsContent || - indexStatusContent + Boolean(statusIndicatorContent) || + chips.length > 0 || + !isAtBottom || + showStop return ( {statusIndicatorContent} - - {!isAtBottom && } - - - {contextWindowContent} - {sessionCostContent} - {diffStatsContent} - {indexStatusContent} - {modelNameContent} - {elapsedTimeContent} - {onStop && - (statusIndicatorState.kind === 'waiting' || - statusIndicatorState.kind === 'streaming') && ( - ■ Esc - )} + {!isAtBottom && ( + + + + )} + + {chips.map((chip, index) => ( + + {index > 0 && · } + + {chip.label} + + + ))} + + {showStop && onStop && ( + ■ Esc + )} ) diff --git a/cli/src/data/initial-agent-type-sources.generated.ts b/cli/src/data/initial-agent-type-sources.generated.ts index 1266f4c9eb..a8599208c6 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 | 'apply_patch'\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 apply_patch: ApplyPatchParams\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 * Parameters for apply_patch tool\n */\nexport interface ApplyPatchParams {\n operation:\n | {\n type: 'create_file'\n path: string\n diff: string\n }\n | {\n type: 'update_file'\n path: string\n diff: string\n basedOnRead?: string[]\n }\n | {\n type: 'delete_file'\n path: string\n }\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 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 skipIfMissing?: boolean\n }[]\n}\n\n/**\n * Suggest clickable followup prompts to the user. Each followup becomes a card the user can click to send that prompt.\n */\nexport interface SuggestFollowupsParams {\n /** List of suggested followup prompts the user can click to send */\n followups: {\n /** The full prompt text to send as a user message when clicked */\n prompt: string\n /** Short display label for the card (defaults to truncated prompt if not provided) */\n label?: string\n }[]\n}\n\n/**\n * Signal that the task is complete. Use this tool when:\n- The user's request is completely fulfilled\n- You need clarification from the user before continuing\n- You are stuck or need help from the user to continue\n\nThis tool explicitly marks the end of your work on the current task.\n */\nexport interface TaskCompletedParams {}\n\n/**\n * Deeply consider complex tasks by brainstorming approaches and tradeoffs step-by-step.\n */\nexport interface ThinkDeeplyParams {\n /** Detailed step-by-step analysis. Initially keep each step concise (max ~5-7 words per step). */\n thought: string\n}\n\n/**\n * Parameters for update_plan_status tool\n */\nexport interface UpdatePlanStatusParams {\n /** Artifact path. Must be `.agents/sessions//PLAN.md`, `.agents/sessions//STATUS.md`, or `.agents/sessions//LESSONS.md`. Absolute paths and `..` traversal are rejected. Editing PLAN.md is permitted only for tri-state task toggles (not full overwrites). */\n path: string\n /** Targeted updates applied in order. Each entry rewrites at most one matching checklist line; unmatched updates fall through to `append`. */\n updates?: {\n /** Stable task ID at the start of a checklist line (for example `P2-T3`). Preferred over substring matching. */\n taskId?: string\n /** Substring of the existing task/checklist line to match (case-insensitive). The first matching `- [ ]`/`-[x]`/`-[~]`/`-[/]`/`-[!]` line in the artifact will be updated in place. */\n task?: string\n /** When provided, sets the checkbox state of the matched line (true -> `[x]`, false -> `[ ]`). Ignored when `status` is also provided. */\n completed?: boolean\n /** Explicit tri-state task status. When provided, overrides `completed`. Transitions a task to `in_progress` (`[~]`), `done` (`[x]`), `cancelled` (`[/]`), `blocked` (`[!]`), or back to `pending` (`[ ]`). */\n status?: 'pending' | 'in_progress' | 'done' | 'cancelled' | 'blocked'\n /** Optional short note to append to the matched line in parentheses. Preserves any existing trailing text on the line. */\n note?: string\n }[]\n /** Optional delimited entry appended at the end of the artifact (used when there is no matching task line for the change being recorded). */\n append?: {\n /** Short heading for an appended entry. Used to form a clearly delimited block (`## `). */\n heading: string\n /** Markdown body for the appended entry. Written verbatim under the heading. */\n body: string\n }\n /** Optional session-level status transition. When provided, `.agents/sessions//STATE.json` is created or updated to reflect the new lifecycle status. */\n sessionStatus?:\n | 'draft'\n | 'ready'\n | 'active'\n | 'executing'\n | 'validating'\n | 'reviewing'\n | 'blocked'\n | 'paused'\n | 'completed'\n | 'archived'\n /** Optional current-task pointer written as a `` annotation in PLAN.md. Pass an empty string or omit to clear the pointer. Only takes effect when path targets PLAN.md. */\n currentTask?: string\n /** Optional STATE.json compare-and-swap revision. The update fails without writing when the current revision differs. */\n expectedRevision?: number\n /** Validation or review evidence associated with a stable task ID. Completing a PLAN task requires a passed validation checkpoint with receiptIds. */\n checkpoint?: {\n taskId: string\n phase: 'validation' | 'review'\n passed: boolean\n summary?: string\n receiptIds?: string[]\n }\n}\n\n/**\n * Search the web for current information, or fetch the content of a specific URL.\n */\nexport interface WebSearchParams {\n /** The search query to find relevant web content. Required unless url is provided. */\n query?: string\n /** A specific URL to fetch and read the full text content of. When provided, fetches this page directly instead of searching. Useful for reading documentation, GitHub READMEs, blog posts, or any public web page. */\n url?: string\n /** Search depth - 'standard' for quick results, 'deep' for more comprehensive search. Default is 'standard'. Ignored when url is provided. */\n depth?: 'standard' | 'deep'\n /** When fetching a URL, also extract and return links found on the page. Enables navigation by letting you see what pages are linked. Default: true. */\n include_links?: boolean\n /** Maximum number of links to extract when include_links is true. Default: 40. */\n max_links?: number\n}\n\n/**\n * Create or overwrite a file with the given content.\n */\nexport interface WriteFileParams {\n /** Path to the file relative to the **project root** */\n path: string\n /** What the change is intended to do in only one sentence. */\n instructions: string\n /** Complete file content to write to the file. */\n content: string\n /** Optional whole-file-covering cap.v3 from a fresh complete whole-file read (paths or full-file range). Only a capability that covers the entire current file (startLine=1 through the current line count) with a hash matching current content may authorize overwrite; partial range capabilities never authorize write_file. */\n basedOnRead?: string\n}\n\n/**\n * Parameters for write_audit_findings tool\n */\nexport interface WriteAuditFindingsParams {\n /** Existing durable audit session slug under .agents/sessions/. */\n sessionSlug: string\n /** Unique shard identifier used as the findings filename. */\n shardId: string\n /** Exact snapshotId returned by inspect_codebase_structure. Required for a directly composable structuralReceipt; omitted only for legacy callers. */\n snapshotId?: string\n findings: {\n severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'\n domain:\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n | 'api-abi'\n path: string\n line?: number\n title: string\n risk: string\n fix: string\n evidence: string\n }[]\n coverage: {\n subsystemIds: string[]\n featureIds: string[]\n files: string[]\n domains?: (\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n )[]\n }\n noIssuesFound?: boolean\n}\n\n/**\n * Write a todo list to track tasks for multi-step implementations. Use this frequently to maintain an updated step-by-step plan.\n */\nexport interface WriteTodosParams {\n /** List of todos with their completion status. Add ALL of the applicable tasks to the list, so you don't forget to do anything. Try to order the todos the same way you will complete them. Do not mark todos as completed if you have not completed them yet! */\n todos: {\n /** Description of the task */\n task: string\n /** Whether the task is completed */\n completed: boolean\n }[]\n}\n\n/**\n * Get parameters type for a specific tool\n */\nexport type GetToolParams = ToolParamsMap[T]\n" +export const toolsSource = "/**\n * Union type of all available tool names\n */\nexport type ToolName =\n | 'apply_patch'\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 apply_patch: ApplyPatchParams\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 * Parameters for apply_patch tool\n */\nexport interface ApplyPatchParams {\n operation:\n | {\n type: 'create_file'\n path: string\n diff: string\n }\n | {\n type: 'update_file'\n path: string\n diff: string\n basedOnRead?: string[]\n }\n | {\n type: 'delete_file'\n path: string\n }\n}\n\n/**\n * Add a new message to the conversation history. To be used for complex requests that can't be solved in a single step, as you may forget what happened!\n */\nexport interface AddMessageParams {\n role: 'user' | 'assistant'\n content: string\n}\n\n/**\n * Ask the user a list of multiple choice questions. Each question must have at least 2 options. The agent execution will pause until the user submits their answers.\n */\nexport interface AskUserParams {\n /** List of multiple choice questions to ask the user */\n questions: {\n /** The question to ask the user */\n question: string\n /** Optional short display label. Values longer than 18 Unicode code points are truncated instead of rejecting the question. */\n header?: string\n /** Array of answer options with label and optional description. */\n options: {\n /** The display text for this option */\n label: string\n /** Explanation shown when option is focused */\n description?: string\n }[]\n /** If true, allows selecting multiple options (checkbox). If false, single selection only (radio). */\n multiSelect?: boolean\n /** Validation rules for \"Other\" text input */\n validation?: {\n /** Maximum length for \"Other\" text input */\n maxLength?: number\n /** Minimum length for \"Other\" text input */\n minLength?: number\n /** Regex pattern for \"Other\" text input */\n pattern?: string\n /** Custom error message when pattern fails */\n patternError?: string\n }\n }[]\n}\n\n/**\n * Join/wait on a background agent turn started by spawn_agents({ background: true }): returns the sequenced agent_chunk events produced since the cursor plus the unified job state. Use it to observe a long-running background agent without blocking the turn.\n */\nexport interface CheckBackgroundAgentParams {\n /** The jobId returned by spawn_agents({ background: true }) for the background agent turn. */\n jobId: string\n /** Optional sequence cursor from a prior response. Polling is idempotent for an explicit cursor; nextCursor can be supplied on the next call. */\n cursor?: number\n /** Optional substring to wait for in the new streamed chunks before returning (follow mode). Returns early as soon as it appears in any chunk payload. Useful for waiting until a background agent emits a specific milestone (e.g. a tool_result or a text marker). */\n wait_for?: string\n /** Max seconds to wait for new chunks / the wait_for pattern. 0 (default) returns immediately with whatever new chunks exist (poll mode); >0 blocks up to this long (follow mode). */\n timeout_seconds?: number\n /** When true, explicitly cancel the running background agent before returning its final status. Defaults to false. */\n cancel?: boolean\n}\n\n/**\n * Join/wait on a background job started by run_terminal_command: returns the sequenced output events produced since the last check plus the unified job state and exit code. Use it to observe a long-running process without blocking the turn. To watch an arbitrary log file, start a `tail -f ` BACKGROUND job and check_job it with a wait_for pattern.\n */\nexport interface CheckJobParams {\n /** The jobId returned by run_terminal_command with process_type: BACKGROUND. */\n jobId: string\n /** Optional substring to wait for in the new output before returning (follow mode). Returns early as soon as it appears (e.g. \"Listening on\" / \"compiled successfully\"). */\n wait_for?: string\n /** Max seconds to wait for new output / the wait_for pattern. 0 (default) returns immediately with whatever new output exists (poll mode); >0 blocks up to this long (follow mode). */\n timeout_seconds?: number\n /** Follow mode only: SIGTERM the job on follow-timeout. Poll mode never kills. Default false. */\n kill_on_timeout?: boolean\n}\n\n/**\n * Search for string patterns in the project's files. This tool uses ripgrep (rg), a fast line-oriented search tool. Use this tool only when read_files is not sufficient to find the files you need.\n */\nexport interface CodeSearchParams {\n /** The pattern to search for. */\n pattern: string\n /** Optional safe ripgrep flags as one string or argv tokens (e.g., \"-i -g *.ts -A 2\" or [\"-i\", \"-g\", \"*.ts\", \"-A\", \"2\"]). Allowed: -i/--ignore-case, -S/--smart-case, -s/--case-sensitive, -w/--word-regexp, -F/--fixed-strings, -U/--multiline, --multiline-dotall, -g/--glob, -t/--type, -T/--type-not, plus context -A/-B/-C (and long forms). JSON quotes delimit the string; do not embed another quote pair around the entire expression. Line numbers are automatic; -n/--line-number are ignored. Output-shape flags such as -c/--count, --count-matches, -l, -v/--invert-match, -r/--replace, --exec, and -z/--null are rejected. */\n flags?: string | string[]\n /** Optional working directory or single file to search within, relative to the project root or absolute. Absolute paths may be outside the project. A directory becomes ripgrep's cwd and scopes the search under that path (plus existing blessed hidden dirs when no paths are given); a file scopes the search to that file only (process cwd = project root when the file is under the project, else the file's parent). Defaults to searching the entire project root. */\n cwd?: string\n /** Optional list of file and/or directory paths to search (relative to the project root, or absolute). When non-empty, ripgrep searches only these targets instead of the whole cwd tree (and does not auto-expand hidden dirs). Can be combined with a file cwd. */\n paths?: string[]\n /** Maximum number of results to return per file. Defaults to 15. There is also a global limit of 250 results across all files. */\n maxResults?: number\n}\n\n/**\n * End your turn, regardless of any new tool results that might be coming. This will allow the user to type another prompt.\n */\nexport interface EndTurnParams {}\n\n/**\n * Parameters for edit_transaction tool\n */\nexport interface EditTransactionParams {\n edits: (\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'str_replace'\n replacements: {\n oldString: string\n newString: string\n allowMultiple?: boolean\n occurrenceIndex?: number\n /** Optional authenticated cap.v3 readCapability copied verbatim from the matching fresh read_files editAnchor. */\n basedOnRead?: string\n /** For deletion replacements only (newString is empty): treat a missing oldString as an already-applied no-op. Use only for explicit idempotent cleanup retries, never for ordinary edits. When every requested change resolves to such a no-op - every replacement of a standalone str_replace call, or every edit of an edit_transaction - the call succeeds with zero file changes and the skip messages rather than failing. When combined with occurrenceIndex, a partially-applied cleanup also skips: fewer remaining exact occurrences than the requested index means that occurrence is treated as already applied. Only valid when newString is empty; both the input and provider schemas reject any other combination. */\n skipIfMissing?: boolean\n }[]\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n /** A structured edit dispatched by operation kind. */\n type: 'structured'\n /** Structured edit operation to apply to this file. */\n operation:\n | {\n /** Deterministic text insertion. */\n kind: 'insert_text'\n /** 1-indexed insertion position. */\n position: {\n /** 1-indexed target line. */\n line: number\n /** 1-indexed target column. */\n column: number\n }\n text: string\n }\n | {\n /** Language-aware import insertion. */\n kind: 'insert_import'\n /** Complete language-native import statement to add, e.g. \"import { foo } from 'bar'\", \"from app import value\", or \"use crate::value\". */\n importStatement: string\n }\n | {\n /** Language-aware import removal. */\n kind: 'remove_import'\n /** Complete language-native import statement to remove. Required unless moduleSpecifier is provided. */\n importStatement?: string\n /** Module specifier to remove imports from, e.g. \"react\" or \"./helper\". */\n moduleSpecifier?: string\n }\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'create'\n /** Exact bytes to write to the new file. */\n content: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'delete'\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'move'\n /** New project-relative path. The destination must be absent. */\n destinationPath: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'replace_range'\n readCapability: string\n startLine?: number\n endLine?: number\n occurrence?: {\n match: string\n occurrence?: number\n }\n newContent: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'rewrite_symbol'\n symbol: string\n content: string\n occurrence?: number\n /** Optional cap.v3 copied from the matching read_files symbol slice. It authorizes exactly the symbol and its contiguous preceding comment block. */\n readCapability?: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'patch'\n diff: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'write_file'\n content: string\n /** Optional whole-file-covering cap.v3 from a fresh complete whole-file read. Only a full-file capability with a hash matching current content may authorize overwrite; partial ranges never authorize write_file. */\n basedOnRead?: string\n }\n )[]\n}\n\n/**\n * Parameters for edit_3d_asset tool\n */\nexport interface Edit3dAssetParams {\n /** Project-relative .blend path. */\n path: string\n /** Exact source hash returned by inspect_3d_asset. */\n source_hash: string\n operations: (\n | {\n type: 'rename_object'\n object: string\n new_name: string\n }\n | {\n type: 'set_object_transform'\n object: string\n location?: any[]\n rotation_degrees?: any[]\n scale?: any[]\n }\n | {\n type: 'set_render_resolution'\n width: number\n height: number\n percentage?: number\n }\n | {\n type: 'set_frame_range'\n start: number\n end: number\n }\n )[]\n}\n\n/**\n * Find several files related to a brief natural language description of the files or the name of a function or class you are looking for.\n */\nexport interface FindFilesParams {\n /** A brief natural language description of the files or the name of a function or class you are looking for. It's also helpful to mention a directory or two to look within. */\n prompt: string\n}\n\n/**\n * List unique file paths whose content matches a pattern, with optional symbol grouping. Built on top of ripgrep (rg).\n */\nexport interface FindFilesMatchingContentParams {\n /** Regex pattern (ripgrep syntax) to match file content against. */\n pattern: string\n /** Optional safe ripgrep flags as one string or argv tokens. Allowed: -i/--ignore-case, -S/--smart-case, -s/--case-sensitive, -w/--word-regexp, -F/--fixed-strings, -U/--multiline, --multiline-dotall, -g/--glob, -t/--type, -T/--type-not. Examples: \"-g *.ts -g *.tsx\" or [\"-g\", \"*.ts\", \"-g\", \"*.tsx\"]. Do not quote the entire expression inside the JSON string. Output-shape flags such as -c/--count, --count-matches, -l, -v/--invert-match, context -A/-B/-C, -r/--replace, --exec, and -z/--null are rejected (this tool forces -l or --json itself). Redundant -n/--line-number inputs are ignored. */\n flags?: string | string[]\n /** Optional working directory or single file to search within, relative to the project root or absolute. Absolute paths may be outside the project. A directory becomes ripgrep's cwd and scopes the search under that path (plus existing blessed hidden dirs); a file scopes the search to that file only (process cwd = project root when the file is under the project, else the file's parent). Defaults to the project root. */\n cwd?: string\n /** Maximum number of unique files to return. Defaults to 100. */\n maxFiles?: number\n /** When true, also return the names of the top-level symbols (functions, classes, methods, exports, constants) that contain each match, plus the per-file match count. Symbol extraction is heuristic and works best for JS/TS/Python/Go/Rust source files; languages without a recognized declaration shape produce an empty symbols list. */\n groupBySymbol?: boolean\n /** Maximum seconds to let ripgrep run before returning partial results. Defaults to 15. */\n timeoutSeconds?: number\n}\n\n/**\n * Read-only git status and (optionally) diff for the current project.\n */\nexport interface GitStatusParams {\n /** When true, also return the unified diff of uncommitted changes. */\n include_diff?: boolean\n /** When true with include_diff, returns the staged diff instead of unstaged. */\n staged?: boolean\n /** Optional path to scope status/diff to (relative to project root). */\n path?: string\n /** Maximum characters of diff output to return. Defaults to 40,000. */\n max_chars?: number\n}\n\n/**\n * Create a new git branch, optionally switching to it. Refuses to branch when the working tree is dirty unless `allow_dirty` is true.\n */\nexport interface GitBranchParams {\n /** Name of the branch to create. Must start with an alphanumeric character and contain only [a-zA-Z0-9._/-]. */\n branch_name: string\n /** When true (default), create AND switch to the branch (`git checkout -b`). When false, only create the branch (`git branch`), leaving the current branch checked out. */\n switch?: boolean\n /** When true, skip the dirty-tree refusal check. Defaults to false — the tool refuses to branch when the working tree has uncommitted changes. */\n allow_dirty?: boolean\n}\n\n/**\n * Parameters for get_task tool\n */\nexport interface GetTaskParams {\n /** Optional plan session slug. Defaults to .agents/ACTIVE_SESSION. */\n session?: string\n}\n\n/**\n * Parameters for get_change_review_bundle tool\n */\nexport interface GetChangeReviewBundleParams {\n max_chars?: number\n}\n\n/**\n * Inspect the current repository/worktree identity and Git state without modifying it.\n */\nexport interface InspectWorkspaceParams {}\n\n/**\n * Parameters for inspect_environment tool\n */\nexport interface InspectEnvironmentParams {}\n\n/**\n * Parameters for inspect_3d_asset tool\n */\nexport interface Inspect3dAssetParams {\n /** Project-relative 3D asset path. */\n path: string\n}\n\n/**\n * Parameters for get_affected_tests tool\n */\nexport interface GetAffectedTestsParams {\n files: string[]\n}\n\n/**\n * Parameters for get_build_targets tool\n */\nexport interface GetBuildTargetsParams {\n files: string[]\n}\n\n/**\n * Parameters for inspect_codebase_structure tool\n */\nexport interface InspectCodebaseStructureParams {\n scope?: string[]\n}\n\n/**\n * Parameters for inspect_feature_completeness tool\n */\nexport interface InspectFeatureCompletenessParams {\n feature: string\n snapshot_id: string\n scope?: string[]\n}\n\n/**\n * Parameters for evaluate_audit_coverage tool\n */\nexport interface EvaluateAuditCoverageParams {\n snapshot_id: string\n structural_receipts: {\n schema_version: 1\n snapshot_id: string\n shard_id: string\n subsystem_ids: string[]\n files: string[]\n domains: (\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n )[]\n }[]\n features: {\n schema_version: 1\n snapshot_id: string\n feature: string\n evidence_kind: 'heuristic' | 'verified'\n evidence: {\n entrypoints: string[]\n implementation: string[]\n consumers: string[]\n tests: string[]\n docs: string[]\n failure_states: string[]\n }\n }[]\n out_of_scope?: {\n id: string\n reason: string\n }[]\n scope?: string[]\n}\n\n/**\n * Search for files matching a glob pattern. Returns matching file paths sorted by modification time (newest first, then path for deterministic ties).\n */\nexport interface GlobParams {\n /** Glob pattern to match files against (e.g., *.js, src/glob/*.ts, glob/test/glob/*.go). */\n pattern: string\n /** Optional working directory or file path, relative to project root. If a directory, the glob pattern is matched against paths relative to this cwd, while returned files remain project-relative. If a file path, the pattern is matched against that file only (full path or basename). If not provided, searches from project root. */\n cwd?: string\n}\n\n/**\n * Cancel a background job started by run_terminal_command.\n */\nexport interface KillJobParams {\n /** The jobId returned by run_terminal_command with process_type: BACKGROUND. */\n jobId: string\n /** Signal to send. Defaults to SIGTERM; use SIGKILL only if graceful termination fails. */\n signal?: 'SIGTERM' | 'SIGKILL'\n}\n\n/**\n * List files and directories in the specified path. Returns separate arrays of file names and directory names.\n */\nexport interface ListDirectoryParams {\n /** Directory path to list, relative to the project root. */\n path: string\n}\n\n/**\n * List this run's background jobs (shell processes and background agents, running and settled) with statuses, bucketed pending process/log output relative to the last check_job consumer cursor (agents usually show pending: 'none'), and a gap flag.\n */\nexport interface ListJobsParams {}\n\n/**\n * Retrieve information about an agent by ID\n */\nexport interface LookupAgentInfoParams {\n /** Agent ID (short local or full published format) */\n agentId: string\n}\n\n/**\n * Query the local codebase graph index to find relevant files ranked by symbol names, imports, headings, paths, doc concepts, and graph relationships. The index is built automatically on startup.\n */\nexport interface QueryIndexParams {\n /** Natural language query or keyword terms describing the files you are looking for. Optional for graph modes when from/to paths are provided. For example: \"authentication\", \"database migrations\", \"editor mutation logic\", \"React components\". */\n query?: string\n /** Maximum number of results to return. Defaults to 20. */\n limit?: number\n /** Optional list of file extensions to filter results (without dot). E.g. [\"ts\", \"tsx\"] for TypeScript only. */\n fileTypes?: string[]\n /** Optional normalized project-relative directory prefixes. Results outside every prefix are excluded before ranking/limiting. */\n pathPrefixes?: string[]\n /** search|explain|neighbors|path|commands|references — see tool description. */\n mode?: 'search' | 'neighbors' | 'path' | 'explain' | 'commands' | 'references'\n /** Optional source file path for neighbors, path, and references modes. */\n from?: string\n /** Optional target file path for path mode. Also used as the seed file for references mode when from is omitted or not indexed. */\n to?: string\n}\n\n/**\n * Fetch up-to-date documentation for libraries and frameworks using Context7 API.\n */\nexport interface ReadDocsParams {\n /** The library or framework name (e.g., \"Next.js\", \"MongoDB\", \"React\"). Use the official name as it appears in documentation if possible. Only public libraries available in Context7's database are supported, so small or private libraries may not be available. */\n libraryTitle: string\n /** Specific topic to focus on (e.g., \"routing\", \"hooks\", \"authentication\") */\n topic: string\n /** Optional maximum number of tokens to return. Defaults to 10000. Values less than 10000 are automatically increased to 10000. */\n max_tokens?: number\n}\n\n/**\n * Read multiple files from disk and return their contents. Use this tool to read as many files as would be helpful to answer the user's request.\n */\nexport interface ReadFilesParams {\n /** Whole-file paths to read. Complete results include editAnchor.readCapability for follow-up edits. */\n paths?: string[]\n /** 1-indexed inclusive line ranges. Sole `paths` entry infers missing path. */\n ranges?: {\n /** Project-relative file path. */\n path: string\n /** 1-indexed inclusive start line. Defaults to 1. */\n startLine?: number\n /** 1-indexed inclusive end line. Defaults to the last line. */\n endLine?: number\n }[]\n /** Contiguous line windows; each complete window mints a scoped cap.v3 editAnchor. */\n windows?: {\n /** File path to read in contiguous line windows, relative to the project root. */\n path: string\n /** Lines per window. Defaults to 400, capped at 5000. */\n windowSize?: number\n /** 1-indexed window number to return. Omit to get the window manifest (totalLines, windowSize, windowCount) plus the first window. */\n window?: number\n }[]\n /** Literal-anchored context blocks with a scoped cap.v3 editAnchor per block. */\n around?: {\n /** File path to read a content-anchored block from, relative to the project root. */\n path: string\n /** Exact literal string to anchor on. Robust to line-number drift. */\n match: string\n /** 1-indexed occurrence of `match` to anchor on. Defaults to 1. */\n occurrence?: number\n /** Lines of context to include on each side of the match, clamped at file boundaries. Defaults to 40, capped at 2000. */\n contextLines?: number\n }[]\n /** Nth top-level symbol by name (rewrite_symbol occurrence semantics); prefer batch `symbols` when possible. */\n symbol?: {\n /** File path to extract a symbol slice from, relative to the project root. */\n path: string\n /** Top-level symbol name (function, class, interface, method) to pull, as shown by read_outline. */\n name: string\n /** When multiple top-level symbols share this name, the 1-indexed one to return. Defaults to 1. Matches rewrite_symbol occurrence semantics. */\n occurrence?: number\n }[]\n /** Named symbol slices with editAnchors; prefer over full reads when names are known. */\n symbols?: {\n /** Project-relative file path. */\n path: string\n /** Symbol names to slice. */\n names: string[]\n }[]\n}\n\n/**\n * Read image files from disk and return them as model-visible image media.\n */\nexport interface ReadImageParams {\n /** List of image file paths to read. */\n paths: string[]\n}\n\n/**\n * Parameters for render_3d_preview tool\n */\nexport interface Render3dPreviewParams {\n /** Project-relative 3D asset path. */\n path: string\n views?: ('camera' | 'perspective' | 'front' | 'side' | 'top')[]\n mode?: 'material' | 'clay' | 'wireframe'\n width?: number\n height?: number\n}\n\n/**\n * Read the last N lines from a log/text file or background job log without starting a background tail process.\n */\nexport interface ReadLogsParams {\n /** Path to the log file, relative to the project root unless absolute. Required unless jobId is provided. */\n path?: string\n /** Background job id returned by run_terminal_command(process_type: BACKGROUND). When provided, reads the job log file directly. */\n jobId?: string\n /** Number of trailing lines to read. Defaults to 200. */\n lines?: number\n /** Maximum characters to return. Defaults to 20,000. */\n max_chars?: number\n}\n\n/**\n * Generate an outline of imports, exports, classes, methods, and function signatures in a source file without reading the entire implementation.\n */\nexport interface ReadOutlineParams {\n /** File path to generate the AST-like outline for, relative to the project root. */\n path: string\n}\n\n/**\n * Read one or more directory subtrees (as a blob including subdirectories, file names, and parsed variables within each source file) or return parsed variable names for files. If no paths are provided, returns the entire project tree.\n */\nexport interface ReadSubtreeParams {\n /** List of paths to directories or files. Relative to the project root. If omitted, the entire project tree is used. */\n paths?: string[]\n /** Maximum token budget for the subtree blob; the tree will be truncated to fit within this budget by first dropping file variables and then removing the most-nested files and directories. */\n maxTokens?: number\n}\n\n/**\n * Replace all of, a contained sub-range of, or the Nth literal occurrence inside content observed through one fresh cap.v3 read capability.\n */\nexport interface ReplaceRangeParams {\n /** The path to the file to edit. */\n path: string\n /** Copy the cap.v3 readCapability verbatim from the matching fresh read_files editAnchor. The token supplies the observed line bounds and content hash. */\n readCapability: string\n /** Optional 1-indexed target start within the capability-covered range. Omit with endLine to replace the complete observed range. */\n startLine?: number\n /** Optional 1-indexed target end within the capability-covered range. Omit with startLine to replace the complete observed range. */\n endLine?: number\n /** Optional occurrence targeting: replace the 1-indexed occurrence (default 1) of the exact literal match found inside the capability-authorized range. Mutually exclusive with startLine/endLine. */\n occurrence?: {\n match: string\n occurrence?: number\n }\n /** Complete replacement content for the selected line range. */\n newContent: string\n}\n\n/**\n * Replace a whole symbol's definition by name using the file's syntax tree, without copying its current text. Resolves the exact AST range and applies it through the safe str_replace path (atomic, anchored).\n */\nexport interface RewriteSymbolParams {\n /** File path containing the symbol, relative to the project root. */\n path: string\n /** Name of the function/class/method/type/interface to replace (as shown by read_outline). */\n symbol: string\n /** The complete new source for the symbol, replacing its entire current definition (e.g. the whole function including its signature and body). Provide REAL newlines/tabs in the string — literal backslash-n (\\n) and backslash-t (\\t) sequences are not interpreted and will be written verbatim into the file. This matches str_replace. */\n content: string\n /** When multiple top-level symbols share this name, the 1-indexed one to replace. */\n occurrence?: number\n /** Optional cap.v3 copied from the matching read_files symbol slice. Under strict read-before-edit this authorizes exactly the symbol and its contiguous preceding comment block. */\n readCapability?: string\n}\n\n/**\n * Render a small interactive UI widget in the Openbuff CLI. Currently supports a button that opens a link.\n */\nexport interface RenderUiParams {\n /** The UI widget to render. */\n widget: {\n /** Widget type. Currently, the only supported widget is button. */\n type: 'button'\n /** Short button label shown to the user. */\n text: string\n /** The http:// or https:// URL to open when the user clicks the button. */\n link: string\n /** Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions. */\n variant?: 'primary' | 'secondary'\n }\n}\n\n/**\n * Parameters for run_file_change_hooks tool\n */\nexport interface RunFileChangeHooksParams {\n /** List of file paths that were changed and should trigger file change hooks */\n files: string[]\n}\n\n/**\n * Parameters for run_targeted_validation tool\n */\nexport interface RunTargetedValidationParams {\n snapshot_id: string\n files: string[]\n artifact_kinds?: string[]\n}\n\n/**\n * Execute a CLI command from the **project root** (different from the user's cwd).\n */\nexport interface RunTerminalCommandParams {\n /** CLI command valid for user's OS. */\n command: string\n /** SYNC (default) for finite commands that exit: waits and returns output. BACKGROUND only for long-running or never-exiting processes (dev servers, watchers, log tails): starts a detached job and returns a jobId immediately so the turn is not blocked. Live job_update already drives the user UI; use check_job for agent-side readiness/exitCode/join, not solely for user progress. */\n process_type?: 'SYNC' | 'BACKGROUND'\n /** For BACKGROUND commands only: keep the job running if the owning request is cancelled. Defaults to false. */\n detach?: boolean\n /** The working directory to run the command in. Default is the project root. */\n cwd?: string\n /** Set to -1 for no timeout. Does not apply for BACKGROUND commands. Default 30 */\n timeout_seconds?: number\n /** Runtime-managed background job owner; agents must omit. */\n owner?: {\n clientSessionId: string\n rootRunId: string\n parentRunId: string\n parentAgentId: string\n }\n}\n\n/**\n * Atomically replace conversation history and, when supplied, commit a validated structured task-memory revision.\n */\nexport interface SetMessagesParams {\n messages: any\n taskMemory?: {\n schemaVersion: 1\n goal?: string\n requirements?: string[]\n decisions?: string[]\n filesInspected?: string[]\n editsMade?: string[]\n validationResults?: string[]\n reviewReceipts?: string[]\n blockers?: string[]\n nextActions?: string[]\n historicalSummary?: string\n evidence?: {\n id: string\n kind:\n | 'requirement'\n | 'decision'\n | 'read'\n | 'edit'\n | 'validation'\n | 'review'\n | 'blocker'\n | 'handoff'\n | 'note'\n summary: string\n source?: string\n path?: string\n freshnessHash?: string\n workspaceRevision?: number\n verifiedAt?: number\n supersedes?: string[]\n stale?: boolean\n }[]\n workspaceRevision?: number\n workspaceSnapshotId?: string\n }\n expectedTaskMemoryRevision?: number\n}\n\n/**\n * JSON object to set as the agent output. The shape of the parameters are specified dynamically further down in the conversation. This completely replaces any previous output. If the agent was spawned, this value will be passed back to its parent. If the agent has an outputSchema defined, the output will be validated against it.\n */\nexport interface SetOutputParams {\n data?: Record\n [key: string]: any\n}\n\n/**\n * Load a skill by name to get its full instructions. Skills provide reusable behaviors and instructions.\n */\nexport interface SkillParams {\n /** The name of the skill to load */\n name: string\n}\n\n/**\n * Spawn up to 12 agents and send a prompt and/or parameters to each of them. These agents will run in parallel. Note that that means they will run independently. Split larger work into bounded waves. If you need to run agents sequentially, use spawn_agents with one agent at a time instead.\n */\nexport interface SpawnAgentsParams {\n agents: {\n /** Agent to spawn. Must be a name from the live \"You can spawn the following agents\" catalog (hyphenated ids; underscores accepted). */\n agent_type: string\n /** Prompt to send to the agent */\n prompt?: string\n /** If true, return jobId immediately and run as in-process coroutine; poll with check_background_agent. Defaults to false (blocking). Cannot outlive this CLI session. */\n background?: boolean\n /** Optional structured handoff; additive — non-consumers still get prompt/params. */\n handoff?:\n | {\n schemaVersion: 1\n taskId: string\n role:\n | 'orchestrator'\n | 'explorer'\n | 'thinker'\n | 'editor'\n | 'repair-editor'\n | 'test-writer'\n | 'doc-writer'\n | 'dependency-manager'\n | 'debugger'\n | 'validator'\n | 'reviewer'\n | 'security-reviewer'\n | 'committer'\n | 'synthesizer'\n | 'specialist'\n | 'general'\n objective: string\n requirements: {\n id: string\n text: string\n required: boolean\n }[]\n acceptanceCriteria: {\n id: string\n behavior: string\n verification: string\n }[]\n context:\n | {\n path: string\n symbols: string[]\n reason: string\n confidence: 'confirmed' | 'inferred' | 'unknown'\n freshnessHash?: string\n workspaceRevision?: number\n }[]\n | Record\n | string\n currentBehavior?: string\n desiredBehavior?: string\n invariants?: string[]\n nonGoals: string[]\n risks?: string[]\n unknowns?: string[]\n findings: {\n id: string\n text: string\n files: string[]\n snapshotFingerprint: string\n }[]\n permissions: {\n readablePaths: string[]\n writablePaths: string[]\n allowedTools: string[]\n }\n workspaceRevision?: number\n workspaceSnapshotId?: string\n summary?: string\n artifacts?: string[]\n successCriteria?: string[]\n constraints?: string[]\n }\n | Record\n /** Optional wall-clock deadline seconds; omit or -1 for none. Agent defaultTimeoutMs still applies when set. */\n timeout_seconds?: number\n /** Parameters object for the agent */\n params?: {\n /** Terminal command to run (basher, tmux-cli) */\n command?: string\n /** What information from the command output is desired (basher) */\n what_to_summarize?: string\n /** Timeout for command. Set to -1 for no timeout. Default 30 (basher) */\n timeout_seconds?: number\n /** Save full command output to a /tmp log and extract failure lines for long SYNC command output (basher) */\n save_full_log?: boolean\n /** grep -E failure extraction pattern used with save_full_log (basher) */\n failure_pattern?: string\n /** Maximum extracted failure lines to return with save_full_log (basher) */\n max_failure_lines?: number\n /** Array of code search queries (code-searcher) */\n searchQueries?: {\n /** The pattern to search for */\n pattern: string\n /** Optional ripgrep flags as one string or argv tokens (e.g. \"-i -g *.ts\" or [\"-i\", \"-g\", \"*.ts\"]). Do not quote the entire expression inside the JSON string. */\n flags?: string | string[]\n /** Optional working directory relative to project root */\n cwd?: string\n /** Max results per file. Default 15 */\n maxResults?: number\n }[]\n /** Relevant file paths to read (general-agent) */\n filePaths?: string[]\n /** Relevant directory paths to inventory (general-agent) */\n directoryPaths?: string[]\n /** Directories to search within (file-picker) */\n directories?: string[]\n /** Starting URL to navigate to (browser-use) */\n url?: string\n /** Exact task-owned paths eligible for staging (git-committer) */\n owned_paths?: string[]\n /** Optional branch to create or switch to (git-committer) */\n branch_name?: string\n /** Create and switch to branch_name when true (git-committer) */\n branch_switch?: boolean\n /** Allow branch create/switch on a dirty worktree (git-committer) */\n allow_dirty_branch?: boolean\n /** Push the resulting feature branch when authorized (git-committer) */\n push?: boolean\n /** Remote used for fetch/push (git-committer) */\n remote?: string\n /** Assigned gate snapshot fingerprint (reviewer specialists) */\n snapshot_id?: string\n /** Changed file paths to review (security-reviewer) */\n changed_files?: string[]\n /** Opaque snapshot token to echo (security-reviewer) */\n snapshot_fingerprint?: string\n /** Package manager selected from repository manifests (dependency-manager) */\n manager?: string\n /** Dependency operation: add, remove, sync, restore, or update (dependency-manager) */\n operation?: string\n /** Exact package specifications (dependency-manager) */\n packages?: string[]\n /** Optional workspace selector (dependency-manager) */\n workspace?: string\n /** GitHub repository URL to clone (librarian) */\n repoUrl?: string\n /** Retain the owned /tmp clone after completion (librarian) */\n retainClone?: boolean\n /** Optional search or path patterns */\n patterns?: string[]\n /** Exact files in scope (reviewer specialists) */\n files?: string[]\n /** Optional agent-specific prompts */\n prompts?: string[]\n [key: string]: any\n }\n }[]\n}\n\n/**\n * Parameters for str_replace tool\n */\nexport interface StrReplaceParams {\n /** The file to edit. */\n path: string\n atomic?: boolean\n replacements: {\n oldString: string\n newString: string\n allowMultiple?: boolean\n occurrenceIndex?: number\n /** Optional authenticated cap.v3 readCapability copied verbatim from the matching fresh read_files editAnchor. */\n basedOnRead?: string\n /** For deletion replacements only (newString is empty): treat a missing oldString as an already-applied no-op. Use only for explicit idempotent cleanup retries, never for ordinary edits. When every requested change resolves to such a no-op - every replacement of a standalone str_replace call, or every edit of an edit_transaction - the call succeeds with zero file changes and the skip messages rather than failing. When combined with occurrenceIndex, a partially-applied cleanup also skips: fewer remaining exact occurrences than the requested index means that occurrence is treated as already applied. Only valid when newString is empty; both the input and provider schemas reject any other combination. */\n skipIfMissing?: boolean\n }[]\n}\n\n/**\n * Suggest clickable followup prompts to the user. Each followup becomes a card the user can click to send that prompt.\n */\nexport interface SuggestFollowupsParams {\n /** List of suggested followup prompts the user can click to send */\n followups: {\n /** The full prompt text to send as a user message when clicked */\n prompt: string\n /** Short display label for the card (defaults to truncated prompt if not provided) */\n label?: string\n }[]\n}\n\n/**\n * Signal that the task is complete. Use this tool when:\n- The user's request is completely fulfilled\n- You need clarification from the user before continuing\n- You are stuck or need help from the user to continue\n\nThis tool explicitly marks the end of your work on the current task.\n */\nexport interface TaskCompletedParams {}\n\n/**\n * Deeply consider complex tasks by brainstorming approaches and tradeoffs step-by-step.\n */\nexport interface ThinkDeeplyParams {\n /** Detailed step-by-step analysis. Initially keep each step concise (max ~5-7 words per step). */\n thought: string\n}\n\n/**\n * Parameters for update_plan_status tool\n */\nexport interface UpdatePlanStatusParams {\n /** Artifact path. Must be `.agents/sessions//PLAN.md`, `.agents/sessions//STATUS.md`, or `.agents/sessions//LESSONS.md`. Absolute paths and `..` traversal are rejected. Editing PLAN.md is permitted only for tri-state task toggles (not full overwrites). */\n path: string\n /** Targeted updates applied in order. Each entry rewrites at most one matching checklist line; unmatched updates fall through to `append`. */\n updates?: {\n /** Stable task ID at the start of a checklist line (for example `P2-T3`). Preferred over substring matching. */\n taskId?: string\n /** Substring of the existing task/checklist line to match (case-insensitive). The first matching `- [ ]`/`-[x]`/`-[~]`/`-[/]`/`-[!]` line in the artifact will be updated in place. */\n task?: string\n /** When provided, sets the checkbox state of the matched line (true -> `[x]`, false -> `[ ]`). Ignored when `status` is also provided. */\n completed?: boolean\n /** Explicit tri-state task status. When provided, overrides `completed`. Transitions a task to `in_progress` (`[~]`), `done` (`[x]`), `cancelled` (`[/]`), `blocked` (`[!]`), or back to `pending` (`[ ]`). */\n status?: 'pending' | 'in_progress' | 'done' | 'cancelled' | 'blocked'\n /** Optional short note to append to the matched line in parentheses. Preserves any existing trailing text on the line. */\n note?: string\n }[]\n /** Optional delimited entry appended at the end of the artifact (used when there is no matching task line for the change being recorded). */\n append?: {\n /** Short heading for an appended entry. Used to form a clearly delimited block (`## `). */\n heading: string\n /** Markdown body for the appended entry. Written verbatim under the heading. */\n body: string\n }\n /** Optional session-level status transition. When provided, `.agents/sessions//STATE.json` is created or updated to reflect the new lifecycle status. */\n sessionStatus?:\n | 'draft'\n | 'ready'\n | 'active'\n | 'executing'\n | 'validating'\n | 'reviewing'\n | 'blocked'\n | 'paused'\n | 'completed'\n | 'archived'\n /** Optional current-task pointer written as a `` annotation in PLAN.md. Pass an empty string or omit to clear the pointer. Only takes effect when path targets PLAN.md. */\n currentTask?: string\n /** Optional STATE.json compare-and-swap revision. The update fails without writing when the current revision differs. */\n expectedRevision?: number\n /** Validation or review evidence associated with a stable task ID. Completing a PLAN task requires a passed validation checkpoint with receiptIds. */\n checkpoint?: {\n taskId: string\n phase: 'validation' | 'review'\n passed: boolean\n summary?: string\n receiptIds?: string[]\n }\n}\n\n/**\n * Search the web for current information, or fetch the content of a specific URL.\n */\nexport interface WebSearchParams {\n /** The search query to find relevant web content. Required unless url is provided. */\n query?: string\n /** A specific URL to fetch and read the full text content of. When provided, fetches this page directly instead of searching. Useful for reading documentation, GitHub READMEs, blog posts, or any public web page. */\n url?: string\n /** Search depth - 'standard' for quick results, 'deep' for more comprehensive search. Default is 'standard'. Ignored when url is provided. */\n depth?: 'standard' | 'deep'\n /** When fetching a URL, also extract and return links found on the page. Enables navigation by letting you see what pages are linked. Default: true. */\n include_links?: boolean\n /** Maximum number of links to extract when include_links is true. Default: 40. */\n max_links?: number\n}\n\n/**\n * Create or overwrite a file with the given content.\n */\nexport interface WriteFileParams {\n /** Path to the file relative to the **project root** */\n path: string\n /** What the change is intended to do in only one sentence. */\n instructions: string\n /** Complete file content to write to the file. */\n content: string\n /** Optional whole-file-covering cap.v3 from a fresh complete whole-file read (paths or full-file range). Only a capability that covers the entire current file (startLine=1 through the current line count) with a hash matching current content may authorize overwrite; partial range capabilities never authorize write_file. */\n basedOnRead?: string\n}\n\n/**\n * Parameters for write_audit_findings tool\n */\nexport interface WriteAuditFindingsParams {\n /** Existing durable audit session slug under .agents/sessions/. */\n sessionSlug: string\n /** Unique shard identifier used as the findings filename. */\n shardId: string\n /** Exact snapshotId returned by inspect_codebase_structure. Required for a directly composable structuralReceipt; omitted only for legacy callers. */\n snapshotId?: string\n findings: {\n severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'\n domain:\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n | 'api-abi'\n path: string\n line?: number\n title: string\n risk: string\n fix: string\n evidence: string\n }[]\n coverage: {\n subsystemIds: string[]\n featureIds: string[]\n files: string[]\n domains?: (\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n )[]\n }\n noIssuesFound?: boolean\n}\n\n/**\n * Write a todo list to track tasks for multi-step implementations. Use this frequently to maintain an updated step-by-step plan.\n */\nexport interface WriteTodosParams {\n /** List of todos with their completion status. Add ALL of the applicable tasks to the list, so you don't forget to do anything. Try to order the todos the same way you will complete them. Do not mark todos as completed if you have not completed them yet! */\n todos: {\n /** Description of the task */\n task: string\n /** Whether the task is completed */\n completed: boolean\n }[]\n}\n\n/**\n * Get parameters type for a specific tool\n */\nexport type GetToolParams = ToolParamsMap[T]\n" export const 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__/index-status.test.ts b/cli/src/utils/__tests__/index-status.test.ts index ee6e7492f2..236ea8baaa 100644 --- a/cli/src/utils/__tests__/index-status.test.ts +++ b/cli/src/utils/__tests__/index-status.test.ts @@ -46,7 +46,7 @@ describe('formatIndexStatusChip', () => { }) }) - test('shows stale, failed, and ready chips', () => { + test('shows stale and failed chips, hides ready and degraded', () => { expect( formatIndexStatusChip({ state: 'stale', refreshing: false }), ).toEqual({ @@ -61,16 +61,10 @@ describe('formatIndexStatusChip', () => { }) expect( formatIndexStatusChip({ state: 'ready', refreshing: false }), - ).toEqual({ - label: 'idx ready', - tone: 'secondary', - }) + ).toBeNull() expect( formatIndexStatusChip({ state: 'degraded', refreshing: false }), - ).toEqual({ - label: 'idx ready', - tone: 'secondary', - }) + ).toBeNull() }) }) diff --git a/cli/src/utils/__tests__/status-bar-chips.test.ts b/cli/src/utils/__tests__/status-bar-chips.test.ts new file mode 100644 index 0000000000..e53e62c6df --- /dev/null +++ b/cli/src/utils/__tests__/status-bar-chips.test.ts @@ -0,0 +1,890 @@ +import { describe, expect, test } from 'bun:test' +import stringWidth from 'string-width' + +import { + formatStatusTokenCount, + selectStatusBarChips, + shortenStatusModelName, + statusBarChipBudget, + statusBarClusterWidth, + STOP_BUTTON_WIDTH, + type SelectStatusBarChipsInput, + type StatusBarChip, +} from '../status-bar-chips' + +const full = { + contextWindowUsage: { used: 96400, max: 200000 }, // 48% + sessionCostCents: 12, + modelName: 'anthropic/claude-sonnet-4-20250514', + diffStats: { modified: 3, added: 2, deleted: 0 }, + indexChip: null, + elapsedSeconds: 12, + showTimer: true, + showStop: true, + isActive: true, +} satisfies Omit + +const indexChipVariants: SelectStatusBarChipsInput['indexChip'][] = [ + null, + { label: 'idx ready', tone: 'secondary' }, + { label: 'idx building 1234 files', tone: 'warning' }, + { label: 'idx failed: 42 files could not be read', tone: 'error' }, +] + +const byId = (chips: StatusBarChip[]) => + Object.fromEntries(chips.map((chip) => [chip.id, chip])) as Partial< + Record + > + +/** + * Smallest terminal width whose chip budget covers `target`, so overflow tests + * can key off cluster widths instead of magic widths coupled to the + * width-budget ratio. + */ +const widthForBudget = (target: number, showStop: boolean): number => { + for (let terminalWidth = 1; terminalWidth <= 1000; terminalWidth += 1) { + if (statusBarChipBudget(terminalWidth, showStop) >= target) { + return terminalWidth + } + } + throw new Error(`No terminal width fits a chip budget of ${target}`) +} + +describe('selectStatusBarChips', () => { + test('lg includes context bar, shortened model, cost, git, and timer', () => { + const { chips } = selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 180, + }) + const chipsById = byId(chips) + + expect(chips.map((chip) => chip.id)).toEqual([ + 'context', + 'git', + 'model', + 'cost', + 'timer', + ]) + // Below 70% the lg label is a 10-cell bar plus the percent; token counts + // belong to the >=70% branch only. + expect(chipsById.context?.label).toMatch(/^[█░]{10} 48%$/) + expect(chipsById.context?.label).not.toContain('/') + expect(chipsById.context?.label).not.toContain('96.4k') + expect(chipsById.context?.label).not.toContain('ctx') + expect(chipsById.model?.label).not.toContain('anthropic/') + expect(chipsById.cost?.label).toBe('$0.12') + expect(chipsById.cost?.label).not.toContain('cost') + expect(chipsById.git?.label).toBe('~3 +2') + expect(chipsById.git?.label).not.toContain('git') + expect(chipsById.timer?.label).toBe('12s') + }) + + test('lg at high context usage shows token counts and escalates the tone', () => { + const warning = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 200, + contextWindowUsage: { used: 150000, max: 200000 }, // 75% + }).chips, + ) + + expect(warning.context?.tone).toBe('warning') + expect(warning.context?.label).toContain('150k/200k') + expect(warning.context?.label).toContain('75%') + expect(warning.context?.label).toContain('█') + + const error = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 200, + contextWindowUsage: { used: 190000, max: 200000 }, // 95% + }).chips, + ) + + expect(error.context?.tone).toBe('error') + expect(error.context?.label).toContain('190k/200k') + expect(error.context?.label).toContain('95%') + }) + + test('lg tone and label switch exactly at the 70% and 90% thresholds', () => { + const contextAt = (used: number) => + byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 200, + contextWindowUsage: { used, max: 200_000 }, + }).chips, + ).context + + // 69%: below the warning threshold, so the bar-only label and the neutral + // tone are kept. + const belowWarning = contextAt(138_000) + expect(belowWarning?.tone).toBe('secondary') + expect(belowWarning?.label).toMatch(/^[█░]{10} 69%$/) + + // Exactly 70%: warning tone, and lg switches to the token-count label. + const atWarning = contextAt(140_000) + expect(atWarning?.tone).toBe('warning') + expect(atWarning?.label).toContain('140k/200k') + expect(atWarning?.label).toContain('70%') + + // 89%: still warning, one percent below the error threshold. + expect(contextAt(178_000)?.tone).toBe('warning') + + // Exactly 90%: error tone, token-count label retained. + const atError = contextAt(180_000) + expect(atError?.tone).toBe('error') + expect(atError?.label).toContain('180k/200k') + expect(atError?.label).toContain('90%') + }) + + test('clamps the context percent when usage exceeds the max', () => { + const chipsById = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 200, + contextWindowUsage: { used: 300000, max: 200000 }, // 150% raw + }).chips, + ) + + expect(chipsById.context?.label).toContain('100%') + expect(chipsById.context?.label).not.toContain('150%') + expect(chipsById.context?.label).toContain('300k/200k') + // Fully filled bar, no empty cells. + expect(chipsById.context?.label).not.toContain('░') + expect(chipsById.context?.tone).toBe('error') + }) + + test('lg formats sub-cent cost with four decimals and hides a zero cost', () => { + const subCent = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 180, + sessionCostCents: 0.5, + }).chips, + ) + expect(subCent.cost?.label).toBe('$0.0050') + + const zero = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 180, + sessionCostCents: 0, + }).chips, + ) + expect(zero.cost).toBeUndefined() + }) + + test('lg floors a cost below the rendered precision and hides a negative', () => { + const tiny = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 180, + sessionCostCents: 0.0004, + }).chips, + ) + // Would otherwise render '$0.0000', which looks like the hidden zero case. + expect(tiny.cost?.label).toBe('<$0.0001') + + const negative = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 180, + sessionCostCents: -5, + }).chips, + ) + // Same as the zero case: a non-positive cost hides the chip instead of + // rendering a clamped '$0.00'. + expect(negative.cost).toBeUndefined() + }) + + test('git chip is omitted for all-zero diff stats and includes deletions', () => { + const clean = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 180, + diffStats: { modified: 0, added: 0, deleted: 0 }, + }).chips, + ) + expect(clean.git).toBeUndefined() + + const withDeletions = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 180, + diffStats: { modified: 1, added: 0, deleted: 4 }, + }).chips, + ) + expect(withDeletions.git?.label).toBe('~1 -4') + }) + + test('md includes context bar, model, git, and timer, but not cost', () => { + const { chips } = selectStatusBarChips({ + ...full, + widthSize: 'md', + terminalWidth: 120, + }) + const chipsById = byId(chips) + + // md renders a 6-cell bar, half the lg width. + expect(chipsById.context?.label).toMatch(/^[█░]{6} 48%$/) + expect(chipsById.model).toBeDefined() + expect(chipsById.git).toBeDefined() + expect(chipsById.timer).toBeDefined() + expect(chipsById.cost).toBeUndefined() + }) + + test('model label width follows the width size (16 for lg, 12 for md)', () => { + const modelName = 'anthropic/claude-sonnet-4-20250514-preview' + const modelLabel = (widthSize: 'lg' | 'md', terminalWidth: number) => + byId( + selectStatusBarChips({ ...full, widthSize, terminalWidth, modelName }) + .chips, + ).model?.label ?? '' + + const lgLabel = modelLabel('lg', 180) + expect(stringWidth(lgLabel)).toBe(16) + expect(lgLabel.endsWith('…')).toBe(true) + + const mdLabel = modelLabel('md', 120) + expect(stringWidth(mdLabel)).toBe(12) + expect(mdLabel.endsWith('…')).toBe(true) + expect(lgLabel.startsWith(mdLabel.slice(0, -1))).toBe(true) + }) + + test('sm is percent-only context and keeps git when there is no index chip', () => { + const { chips } = selectStatusBarChips({ + ...full, + widthSize: 'sm', + terminalWidth: 80, + }) + const chipsById = byId(chips) + + expect(chipsById.context?.label).toBe('48%') + expect(chipsById.context?.label).not.toContain('█') + expect(chipsById.model).toBeUndefined() + expect(chipsById.cost).toBeUndefined() + expect(chipsById.git?.label).toBe('~3 +2') + }) + + test('sm drops git for a secondary index chip, not only for alerts', () => { + const { chips } = selectStatusBarChips({ + ...full, + widthSize: 'sm', + terminalWidth: 80, + indexChip: { label: 'idx ready', tone: 'secondary' }, + }) + const chipsById = byId(chips) + + expect(chipsById.git).toBeUndefined() + expect(chipsById.index?.label).toBe('idx ready') + expect(chipsById.index?.tone).toBe('secondary') + }) + + test('sm with a warning index chip drops git and never drops the index', () => { + const { chips } = selectStatusBarChips({ + ...full, + widthSize: 'sm', + terminalWidth: 80, + indexChip: { label: 'idx building', tone: 'warning' }, + }) + const chipsById = byId(chips) + + expect(chipsById.git).toBeUndefined() + expect(chipsById.index?.label).toBe('idx building') + expect(chipsById.index?.tone).toBe('warning') + }) + + test('keeps a full error index label outside xs', () => { + // Only 'xs' abbreviates an error label, so wider sizes must keep it intact + // when the budget has room for it. + for (const widthSize of ['sm', 'lg'] as const) { + const chipsById = byId( + selectStatusBarChips({ + ...full, + widthSize, + terminalWidth: 200, + indexChip: { + label: 'idx failed: 42 files could not be read', + tone: 'error', + }, + }).chips, + ) + + expect(chipsById.index?.label).toBe( + 'idx failed: 42 files could not be read', + ) + expect(chipsById.index?.label).not.toContain('!') + expect(chipsById.index?.tone).toBe('error') + } + }) + + test('xs is percent-only and omits model, git, cost, bar, and timer when stop is shown', () => { + const { chips } = selectStatusBarChips({ + ...full, + widthSize: 'xs', + terminalWidth: 40, + }) + const chipsById = byId(chips) + + expect(chips.map((chip) => chip.id)).toEqual(['context']) + expect(chipsById.context?.label).toBe('48%') + expect(chipsById.context?.label).not.toContain('█') + expect(chipsById.model).toBeUndefined() + expect(chipsById.git).toBeUndefined() + expect(chipsById.cost).toBeUndefined() + expect(chipsById.timer).toBeUndefined() + }) + + test('xs with a failed index chip shows idx! and omits context percent', () => { + const { chips } = selectStatusBarChips({ + ...full, + widthSize: 'xs', + terminalWidth: 40, + indexChip: { label: 'idx failed', tone: 'error' }, + }) + const chipsById = byId(chips) + + expect(chips.map((chip) => chip.id)).toEqual(['index']) + expect(chipsById.index?.label).toBe('idx!') + expect(chipsById.index?.tone).toBe('error') + expect(chipsById.context).toBeUndefined() + }) + + test('xs abbreviates an error index label to its own first word', () => { + const { chips } = selectStatusBarChips({ + ...full, + widthSize: 'xs', + terminalWidth: 40, + indexChip: { label: 'search failed', tone: 'error' }, + }) + + expect(byId(chips).index?.label).toBe('search!') + }) + + test('xs marks a space-free error index label without cutting it', () => { + const { chips } = selectStatusBarChips({ + ...full, + widthSize: 'xs', + terminalWidth: 40, + indexChip: { label: 'indexing', tone: 'error' }, + }) + + // No space to split on, so the whole label survives with the '!' marker. + expect(chips.map((chip) => chip.id)).toEqual(['index']) + expect(byId(chips).index?.label).toBe('indexing!') + }) + + test('xs keeps a non-error index label verbatim beside the context percent', () => { + // False side of the xs error-only branches: no '!' suffix on the label, and + // the context percent is not omitted for a non-error index chip. + for (const indexChip of [ + { label: 'idx ready', tone: 'secondary' }, + { label: 'idx building', tone: 'warning' }, + ] as const) { + // Roomy enough for the context percent plus the full index label, so the + // overflow loop leaves both alone and the assertions below are about the + // error-only branches rather than the width budget. + const terminalWidth = widthForBudget( + statusBarClusterWidth([ + { id: 'context', label: '48%', tone: 'secondary' }, + { id: 'index', label: indexChip.label, tone: indexChip.tone }, + ]), + full.showStop, + ) + const chipsById = byId( + selectStatusBarChips({ + ...full, + widthSize: 'xs', + terminalWidth, + indexChip, + }).chips, + ) + + expect(chipsById.index?.label).toBe(indexChip.label) + expect(chipsById.index?.label).not.toContain('!') + expect(chipsById.index?.tone).toBe(indexChip.tone) + expect(chipsById.context?.label).toBe('48%') + } + }) + + test('xs at width 20 keeps only the context percent, within the budget', () => { + const { chips } = selectStatusBarChips({ + ...full, + widthSize: 'xs', + terminalWidth: 20, + }) + + expect(chips.map((chip) => chip.id)).toEqual(['context']) + expect(chips[0]?.label).toBe('48%') + expect(statusBarClusterWidth(chips)).toBeLessThanOrEqual( + statusBarChipBudget(20, true), + ) + }) + + test('md at high context usage keeps the bar and percent, not counts', () => { + const chipsById = byId( + selectStatusBarChips({ + ...full, + widthSize: 'md', + terminalWidth: 120, + contextWindowUsage: { used: 150000, max: 200000 }, // 75% + }).chips, + ) + + // Token counts belong to the lg >=70% branch only. + expect(chipsById.context?.label).toMatch(/^[█░]{6} 75%$/) + expect(chipsById.context?.label).not.toContain('/') + expect(chipsById.context?.tone).toBe('warning') + }) + + test('xs keeps the timer when the stop hint is hidden', () => { + const { chips } = selectStatusBarChips({ + ...full, + widthSize: 'xs', + terminalWidth: 40, + showStop: false, + }) + const chipsById = byId(chips) + + expect(chips.map((chip) => chip.id)).toEqual(['context', 'timer']) + expect(chipsById.timer?.label).toBe('12s') + expect(chipsById.context?.label).toBe('48%') + expect(statusBarClusterWidth(chips)).toBeLessThanOrEqual( + statusBarChipBudget(40, false), + ) + }) + + test('overflow drops the timer before context when idle', () => { + const { chips } = selectStatusBarChips({ + ...full, + widthSize: 'sm', + terminalWidth: 39, + isActive: false, + }) + const chipsById = byId(chips) + + expect(chipsById.timer).toBeUndefined() + expect(chipsById.context?.label).toBe('48%') + expect(statusBarClusterWidth(chips)).toBeLessThanOrEqual( + statusBarChipBudget(39, full.showStop), + ) + }) + + test('overflow drops context before the timer during an active run', () => { + const { chips } = selectStatusBarChips({ + ...full, + widthSize: 'sm', + terminalWidth: 39, + isActive: true, + }) + const chipsById = byId(chips) + + expect(chipsById.context).toBeUndefined() + expect(chipsById.timer?.label).toBe('12s') + expect(statusBarClusterWidth(chips)).toBeLessThanOrEqual( + statusBarChipBudget(39, full.showStop), + ) + }) + + test('overflow drops context before the timer during an active run without the stop hint', () => { + const { chips } = selectStatusBarChips({ + ...full, + widthSize: 'sm', + terminalWidth: 8, + showStop: false, + isActive: true, + }) + const chipsById = byId(chips) + + // Same priority as the showStop case: the live timer outranks context. + expect(chipsById.context).toBeUndefined() + expect(chipsById.timer?.label).toBe('12s') + expect(statusBarClusterWidth(chips)).toBeLessThanOrEqual( + statusBarChipBudget(8, false), + ) + }) + + test('overflow drops cost, then model, then git, and never drops a warning index', () => { + const chipsAt = (terminalWidth: number) => + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth, + }).chips + const idsAt = (terminalWidth: number) => + chipsAt(terminalWidth).map((chip) => chip.id) + + // Budget larger than any lg cluster, so nothing is dropped or shortened. + const allChips = chipsAt(widthForBudget(80, full.showStop)) + const clusterWithout = (dropped: StatusBarChip['id'][]) => + statusBarClusterWidth( + allChips.filter((chip) => !dropped.includes(chip.id)), + ) + + // Each step gives the cluster exactly the budget the surviving chips need, + // so the next-lowest priority chip is the one that has to go. + expect(idsAt(widthForBudget(clusterWithout([]), full.showStop))).toEqual([ + 'context', + 'git', + 'model', + 'cost', + 'timer', + ]) + expect( + idsAt(widthForBudget(clusterWithout(['cost']), full.showStop)), + ).toEqual(['context', 'git', 'model', 'timer']) + expect( + idsAt(widthForBudget(clusterWithout(['cost', 'model']), full.showStop)), + ).toEqual(['context', 'git', 'timer']) + expect( + idsAt( + widthForBudget(clusterWithout(['cost', 'model', 'git']), full.showStop), + ), + ).toEqual(['context', 'timer']) + + const { chips } = selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 60, + }) + const chipsById = byId(chips) + const remaining = statusBarChipBudget(60, full.showStop) + + expect(chipsById.cost).toBeUndefined() + expect(chipsById.model).toBeUndefined() + expect(chipsById.git).toBeUndefined() + expect(chipsById.context?.label).toBe('48%') + expect(statusBarClusterWidth(chips)).toBeLessThanOrEqual(remaining) + + const withIndex = selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 60, + indexChip: { label: 'idx building', tone: 'warning' }, + }) + const indexChip = withIndex.chips.find((chip) => chip.id === 'index') + expect(indexChip?.label).toBe('idx building') + expect(indexChip?.tone).toBe('warning') + expect(byId(withIndex.chips).timer).toBeUndefined() + expect(statusBarClusterWidth(withIndex.chips)).toBeLessThanOrEqual( + remaining, + ) + }) + + test('lg context drops token counts before the bar when overflowing', () => { + const contextAt = (terminalWidth: number) => + byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth, + contextWindowUsage: { used: 150_000, max: 200_000 }, // 75% + }).chips, + ) + + const tokenLabel = contextAt(200).context?.label ?? '' + expect(tokenLabel).toMatch(/^150k\/200k [█░]{10} 75%$/) + + // The same label without its token-count prefix: the intermediate form the + // overflow loop should stop at while it still fits. + const barLabel = tokenLabel.slice(tokenLabel.indexOf(' ') + 1) + const timerLabel = '12s' + // Budget for the surviving cluster only (context plus the live timer), so + // cost, model, and git are dropped and context has to shorten. + const budgetFor = (contextLabel: string) => + statusBarClusterWidth([ + { id: 'context', label: contextLabel, tone: 'warning' }, + { id: 'timer', label: timerLabel, tone: 'secondary' }, + ]) + + const intermediate = contextAt( + widthForBudget(budgetFor(barLabel), full.showStop), + ) + expect(intermediate.context?.label).toBe(barLabel) + expect(intermediate.context?.label).toMatch(/^[█░]{10} 75%$/) + expect(intermediate.context?.tone).toBe('warning') + expect(intermediate.timer?.label).toBe(timerLabel) + + // One column tighter than the intermediate label needs, so the bar goes too + // and only the bare percent survives. + const bare = contextAt( + widthForBudget(budgetFor(barLabel) - 1, full.showStop), + ) + expect(bare.context?.label).toBe('75%') + expect(bare.timer?.label).toBe(timerLabel) + }) + + test('omits the timer when it is hidden or nothing has elapsed', () => { + const hidden = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 180, + showTimer: false, + }).chips, + ) + expect(hidden.timer).toBeUndefined() + + const notStarted = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 180, + elapsedSeconds: 0, + }).chips, + ) + expect(notStarted.timer).toBeUndefined() + }) + + test('omits the context chip for missing usage or a zero max', () => { + const missing = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 180, + contextWindowUsage: null, + }).chips, + ) + expect(missing.context).toBeUndefined() + + const zeroMax = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 180, + contextWindowUsage: { used: 1000, max: 0 }, + }).chips, + ) + expect(zeroMax.context).toBeUndefined() + }) + + test('lg omits the model chip when the model name is null', () => { + const chipsById = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 180, + modelName: null, + }).chips, + ) + expect(chipsById.model).toBeUndefined() + expect(chipsById.context).toBeDefined() + }) + + test('clamps a long index label to the chip budget', () => { + const { chips } = selectStatusBarChips({ + ...full, + widthSize: 'xs', + terminalWidth: 20, + indexChip: { label: 'idx building 1234 files', tone: 'warning' }, + }) + const chipsById = byId(chips) + + expect(chips.map((chip) => chip.id)).toEqual(['index']) + expect(chipsById.index?.label.endsWith('…')).toBe(true) + expect(statusBarClusterWidth(chips)).toBeLessThanOrEqual( + statusBarChipBudget(20, full.showStop), + ) + }) + + test('drops every chip at width 1 with the stop hint', () => { + // The chip budget is zero here, so clamping a label would otherwise leave a + // zero-width chip behind. + expect(statusBarChipBudget(1, true)).toBe(0) + + for (const widthSize of ['xs', 'sm', 'md', 'lg'] as const) { + for (const isActive of [true, false]) { + for (const indexChip of indexChipVariants) { + const { chips } = selectStatusBarChips({ + ...full, + widthSize, + terminalWidth: 1, + showStop: true, + isActive, + indexChip, + }) + + // Nothing fits beside the stop hint, so even the index chip goes. + expect(chips).toEqual([]) + } + } + } + }) + + test('drops the index chip instead of clamping it to a bare ellipsis', () => { + // One column is room for the ellipsis alone, an information-free label. + expect(statusBarChipBudget(8, true)).toBe(1) + + for (const widthSize of ['xs', 'sm', 'md', 'lg'] as const) { + for (const isActive of [true, false]) { + for (const indexChip of indexChipVariants) { + const { chips } = selectStatusBarChips({ + ...full, + widthSize, + terminalWidth: 8, + showStop: true, + isActive, + indexChip, + }) + + expect(chips.map((chip) => chip.label)).not.toContain('…') + expect(chips).toEqual([]) + } + } + } + }) + + test('never returns an empty-label chip where a clamped index chip survives', () => { + // Two columns: room for one character plus the ellipsis, so the index chip + // survives the clamp and the empty-label guard applies to a real label. + expect(statusBarChipBudget(9, true)).toBe(2) + + for (const widthSize of ['xs', 'sm', 'md', 'lg'] as const) { + for (const isActive of [true, false]) { + // Skip the null variant: there is no index chip to clamp there. + for (const indexChip of indexChipVariants.slice(1)) { + const { chips } = selectStatusBarChips({ + ...full, + widthSize, + terminalWidth: 9, + showStop: true, + isActive, + indexChip, + }) + + expect(chips.map((chip) => chip.id)).toEqual(['index']) + for (const chip of chips) { + expect(stringWidth(chip.label)).toBeGreaterThan(0) + expect(chip.label).not.toBe('…') + } + expect(statusBarClusterWidth(chips)).toBeLessThanOrEqual( + statusBarChipBudget(9, true), + ) + } + } + } + }) + + test('never exits overflow handling with an over-budget cluster', () => { + for (const terminalWidth of [1, 8, 12, 20, 39, 60]) { + for (const showStop of [true, false]) { + for (const isActive of [true, false]) { + for (const widthSize of ['xs', 'sm', 'md', 'lg'] as const) { + for (const indexChip of indexChipVariants) { + const { chips } = selectStatusBarChips({ + ...full, + widthSize, + terminalWidth, + showStop, + isActive, + indexChip, + }) + const clusterWidth = statusBarClusterWidth(chips) + + expect(clusterWidth).toBeLessThanOrEqual( + statusBarChipBudget(terminalWidth, showStop), + ) + // The cluster renders beside the stop hint, so the two together + // must still fit the real row width. A terminal narrower than the + // stop hint cannot fit the hint itself, so only the cluster + // contribution is constrained there. + const stopReservation = showStop ? STOP_BUTTON_WIDTH : 0 + expect(clusterWidth + stopReservation).toBeLessThanOrEqual( + Math.max(terminalWidth, stopReservation), + ) + } + } + } + } + } + }) +}) + +describe('statusBarChipBudget', () => { + test('reserves the stop hint but still leaves room for one chip', () => { + expect(statusBarChipBudget(60, false)).toBe(24) + expect(statusBarChipBudget(60, true)).toBe(17) + // Narrow terminal with the stop hint: the floor applies after the + // reservation, so a chip still fits. + expect(statusBarChipBudget(20, true)).toBe(8) + // Below the floor the budget is clamped to the columns left beside the stop + // hint instead of overflowing the row. + expect(statusBarChipBudget(12, true)).toBe(5) + expect(statusBarChipBudget(8, true)).toBe(1) + expect(statusBarChipBudget(1, true)).toBe(0) + expect(statusBarChipBudget(1, false)).toBe(1) + }) +}) + +describe('formatStatusTokenCount', () => { + test('formats integers, thousands, and millions', () => { + expect(formatStatusTokenCount(480)).toBe('480') + expect(formatStatusTokenCount(48200)).toBe('48.2k') + expect(formatStatusTokenCount(100000)).toBe('100k') + expect(formatStatusTokenCount(1_000)).toBe('1k') + expect(formatStatusTokenCount(1_200_000)).toBe('1.2m') + // Fractional counts are compared after rounding, so a value just below + // 1_000 renders as '1k' rather than a 4-column '1000'. + expect(formatStatusTokenCount(999.6)).toBe('1k') + expect(formatStatusTokenCount(999.4)).toBe('999') + // Counts that would round up to '1000k' render as millions instead. + expect(formatStatusTokenCount(999_499)).toBe('999k') + expect(formatStatusTokenCount(999_500)).toBe('1m') + expect(formatStatusTokenCount(999_999)).toBe('1m') + }) +}) + +describe('shortenStatusModelName', () => { + test('strips a leading openai/ prefix', () => { + expect(shortenStatusModelName('openai/gpt-4.1', 16)).toBe('gpt-4.1') + }) + + test('strips the other provider prefixes too', () => { + expect(shortenStatusModelName('openrouter/qwen3-max', 20)).toBe('qwen3-max') + expect(shortenStatusModelName('google/gemini-2.5-pro', 20)).toBe( + 'gemini-2.5-pro', + ) + }) + + test('truncates with an ellipsis when the stripped name is too wide', () => { + expect(shortenStatusModelName('openai/gpt-4.1', 6)).toBe('gpt-4…') + expect(stringWidth(shortenStatusModelName('openai/gpt-4.1', 6))).toBe(6) + }) + + test('omits the ellipsis when maxChars cannot fit it', () => { + expect(shortenStatusModelName('openai/gpt-4.1', 0)).toBe('') + expect(shortenStatusModelName('openai/gpt-4.1', 1)).toBe('…') + expect(stringWidth(shortenStatusModelName('openai/gpt-4.1', 1))).toBe(1) + }) + + test('truncates wide characters by display width, not code point count', () => { + const shortened = shortenStatusModelName('中文模型名', 6) + expect(stringWidth(shortened)).toBeLessThanOrEqual(6) + expect(shortened).toBe('中文…') + }) + + test('keeps a ZWJ emoji sequence whole instead of cutting mid-grapheme', () => { + const emoji = '👩‍💻' + // Room for the sequence plus the ellipsis and nothing more, so the cut + // lands right after the sequence. + const maxChars = stringWidth(emoji) + stringWidth('…') + const shortened = shortenStatusModelName(`${emoji}model`, maxChars) + + expect(shortened).toBe(`${emoji}…`) + // A code-point-wise cut would leave a dangling zero-width joiner here. + expect(shortened).not.toContain('\u200D…') + expect(stringWidth(shortened)).toBeLessThanOrEqual(maxChars) + }) +}) diff --git a/cli/src/utils/index-status.ts b/cli/src/utils/index-status.ts index 29555e6689..cdb37fd331 100644 --- a/cli/src/utils/index-status.ts +++ b/cli/src/utils/index-status.ts @@ -49,9 +49,7 @@ export function formatIndexStatusChip( if (status.state === 'stale') { return { label: 'idx stale', tone: 'warning' } } - if (status.state === 'ready' || status.state === 'degraded') { - return { label: 'idx ready', tone: 'secondary' } - } + // Healthy snapshots are silent; only building / refreshing / stale / failed show. return null } diff --git a/cli/src/utils/status-bar-chips.ts b/cli/src/utils/status-bar-chips.ts new file mode 100644 index 0000000000..8805a32acf --- /dev/null +++ b/cli/src/utils/status-bar-chips.ts @@ -0,0 +1,398 @@ +import stringWidth from 'string-width' + +import { formatElapsedTime } from './format-elapsed-time' + +export type StatusBarChipId = + | 'context' + | 'index' + | 'git' + | 'model' + | 'cost' + | 'timer' + +export type StatusBarChipTone = 'muted' | 'secondary' | 'warning' | 'error' + +export type StatusBarChip = { + id: StatusBarChipId + label: string + tone: StatusBarChipTone +} + +export type StatusBarWidthSize = 'xs' | 'sm' | 'md' | 'lg' + +export type SelectStatusBarChipsInput = { + widthSize: StatusBarWidthSize + terminalWidth: number + contextWindowUsage?: { used: number; max: number } | null + sessionCostCents?: number | null + modelName?: string | null + diffStats?: { modified: number; added: number; deleted: number } | null + /** + * Index status. At 'xs' an error label is abbreviated to its first word plus + * '!', so the label should lead with its subject (e.g. 'idx failed: …'). + */ + indexChip?: { label: string; tone: 'secondary' | 'warning' | 'error' } | null + elapsedSeconds: number + showTimer: boolean + showStop: boolean + isActive: boolean // waiting or streaming +} + +const PROVIDER_PREFIX = /^(openai|anthropic|google|openrouter)\// + +/** Fraction of the terminal width the chip cluster may occupy. */ +const WIDTH_BUDGET_RATIO = 0.4 +/** Floor so very narrow terminals still get room for one chip. */ +const MIN_WIDTH_BUDGET = 8 +/** Columns reserved for the stop-button hint rendered beside the chips. */ +export const STOP_BUTTON_WIDTH = 7 +/** Columns rendered between two adjacent chips. */ +const CHIP_SEPARATOR_WIDTH = 3 +/** Suffix appended to a truncated status chip label. */ +const LABEL_ELLIPSIS = '…' + +export function formatStatusTokenCount(tokens: number): string { + // Every threshold compares the rounded count, so a fractional value just + // below 1_000 renders as '1k' instead of a 4-column '1000'. + const roundedTokens = Math.round(tokens) + if (roundedTokens < 1_000) return roundedTokens.toString() + if (roundedTokens < 1_000_000) { + const value = roundedTokens / 1_000 + if (value < 100) return `${value.toFixed(1).replace(/\.0$/, '')}k` + const roundedThousands = Math.round(value) + if (roundedThousands < 1_000) return `${roundedThousands}k` + } + return `${(roundedTokens / 1_000_000).toFixed(1).replace(/\.0$/, '')}m` +} + +/** Shared because constructing a segmenter per truncation is needlessly costly. */ +const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { + granularity: 'grapheme', +}) + +/** Truncate to a rendered width, marking the cut with an ellipsis when it fits. */ +const truncateStatusLabel = (label: string, maxChars: number): string => { + if (stringWidth(label) <= maxChars) return label + + // Below the ellipsis width there is no room to mark the truncation without + // exceeding maxChars, so hard-truncate instead. + const withEllipsis = maxChars >= stringWidth(LABEL_ELLIPSIS) + const budget = Math.max( + 0, + withEllipsis ? maxChars - stringWidth(LABEL_ELLIPSIS) : maxChars, + ) + let truncated = '' + let width = 0 + // Grapheme clusters rather than code points, so a ZWJ emoji sequence or a + // combining mark is never cut in half (which would leave a dangling joiner + // or reattach the mark to the ellipsis). + for (const { segment: cluster } of GRAPHEME_SEGMENTER.segment(label)) { + const clusterWidth = stringWidth(cluster) + if (width + clusterWidth > budget) break + truncated += cluster + width += clusterWidth + } + return withEllipsis ? `${truncated}${LABEL_ELLIPSIS}` : truncated +} + +export function shortenStatusModelName( + modelName: string, + maxChars: number, +): string { + return truncateStatusLabel(modelName.replace(PROVIDER_PREFIX, ''), maxChars) +} + +const contextTone = (pct: number): StatusBarChipTone => { + if (pct >= 90) return 'error' + if (pct >= 70) return 'warning' + return 'secondary' +} + +/** Bare percent label, shared by the full labels and the overflow-shorten step. */ +const percentLabel = (pct: number): string => `${pct}%` + +/** `pct` must already be clamped to 0..100 by the caller. */ +const buildUsageBar = (pct: number, length: number): string => { + const filled = Math.round((pct / 100) * length) + return `${'█'.repeat(filled)}${'░'.repeat(length - filled)}` +} + +/** Bar cell count, or null for the sizes that render the percent only. */ +const contextBarLength = (widthSize: StatusBarWidthSize): number | null => { + if (widthSize === 'lg') return 10 + if (widthSize === 'md') return 6 + return null +} + +/** ' ', or the bare percent for sizes that render no bar. */ +const barPercentLabel = ( + widthSize: StatusBarWidthSize, + pct: number, +): string => { + const barLength = contextBarLength(widthSize) + return barLength == null + ? percentLabel(pct) + : `${buildUsageBar(pct, barLength)} ${percentLabel(pct)}` +} + +/** + * Progressively shorter context labels for the overflow loop, widest first, so + * the lg token-count label gives up its counts before its bar instead of + * collapsing straight to the bare percent. Sizes that render no bar have the + * bare percent as their only form, so they return a single entry instead of + * repeating it. The first entry is also the widest form buildContextLabel + * renders, so the two cannot drift. + */ +const contextLabelFallbacks = ( + widthSize: StatusBarWidthSize, + pct: number, +): [string, ...string[]] => + contextBarLength(widthSize) == null + ? [percentLabel(pct)] + : [barPercentLabel(widthSize, pct), percentLabel(pct)] + +const buildContextLabel = ( + widthSize: StatusBarWidthSize, + usage: { used: number; max: number }, + pct: number, +): string => { + const [barPercent] = contextLabelFallbacks(widthSize, pct) + + if (widthSize === 'lg' && pct >= 70) { + return `${formatStatusTokenCount(usage.used)}/${formatStatusTokenCount(usage.max)} ${barPercent}` + } + + return barPercent +} + +/** + * Only reached for a positive cost: a zero or negative sessionCostCents hides + * the chip entirely, so there is no '$0.00' or '$-0.0100' case here. Sub-cent + * costs render with four decimals, and anything smaller than that precision + * renders as '<$0.0001' so a tiny non-zero cost stays distinguishable from the + * hidden zero case rather than showing a misleading '$0.0000'. + */ +const formatCostLabel = (sessionCostCents: number): string => { + const dollars = sessionCostCents / 100 + if (dollars >= 0.01) return `$${dollars.toFixed(2)}` + const fixed = dollars.toFixed(4) + return fixed === '0.0000' ? '<$0.0001' : `$${fixed}` +} + +const formatGitLabel = (diffStats: { + modified: number + added: number + deleted: number +}): string | null => { + const { modified, added, deleted } = diffStats + if (modified + added + deleted <= 0) return null + const parts: string[] = [] + if (modified > 0) parts.push(`~${modified}`) + if (added > 0) parts.push(`+${added}`) + if (deleted > 0) parts.push(`-${deleted}`) + return parts.join(' ') +} + +/** 'xs' has no room for a full error label, so keep only its subject word. */ +const abbreviateIndexErrorLabel = (label: string): string => { + const firstSpace = label.indexOf(' ') + return `${firstSpace === -1 ? label : label.slice(0, firstSpace)}!` +} + +/** Only 'lg' and 'md' render the model chip. */ +const modelMaxChars = (widthSize: 'lg' | 'md'): number => + widthSize === 'lg' ? 16 : 12 + +/** Rendered width of a chip cluster, including the inter-chip separators. */ +export function statusBarClusterWidth(chips: StatusBarChip[]): number { + if (chips.length === 0) return 0 + const separators = CHIP_SEPARATOR_WIDTH * (chips.length - 1) + return chips.reduce((sum, chip) => sum + stringWidth(chip.label), separators) +} + +/** Columns available to the chip cluster for a given terminal width. */ +export function statusBarChipBudget( + terminalWidth: number, + showStop: boolean, +): number { + const stopReservation = showStop ? STOP_BUTTON_WIDTH : 0 + const available = + Math.floor(terminalWidth * WIDTH_BUDGET_RATIO) - stopReservation + // The floor applies after the stop-hint reservation so one chip still fits in + // a narrow terminal, then the result is clamped to the columns actually left + // beside the stop hint so the cluster can never overflow the real row width. + return Math.max( + 0, + Math.min( + Math.max(MIN_WIDTH_BUDGET, available), + terminalWidth - stopReservation, + ), + ) +} + +const removeChip = (chips: StatusBarChip[], id: StatusBarChipId): boolean => { + const index = chips.findIndex((chip) => chip.id === id) + if (index === -1) return false + chips.splice(index, 1) + return true +} + +export function selectStatusBarChips(input: SelectStatusBarChipsInput): { + chips: StatusBarChip[] +} { + const { + widthSize, + terminalWidth, + contextWindowUsage, + sessionCostCents, + modelName, + diffStats, + indexChip, + elapsedSeconds, + showTimer, + showStop, + isActive, + } = input + + const chips: StatusBarChip[] = [] + let contextPct: number | null = null + + const hasIndexError = indexChip?.tone === 'error' + const omitContextForIndexError = widthSize === 'xs' && hasIndexError + + if ( + contextWindowUsage && + contextWindowUsage.max > 0 && + !omitContextForIndexError + ) { + // Clamped so an over-full context (used > max) renders '100%' with a full + // bar instead of an out-of-range percent such as '150%'. + contextPct = Math.min( + 100, + Math.max( + 0, + Math.round((contextWindowUsage.used / contextWindowUsage.max) * 100), + ), + ) + chips.push({ + id: 'context', + label: buildContextLabel(widthSize, contextWindowUsage, contextPct), + tone: contextTone(contextPct), + }) + } + + if (indexChip) { + chips.push({ + id: 'index', + label: + widthSize === 'xs' && hasIndexError + ? abbreviateIndexErrorLabel(indexChip.label) + : indexChip.label, + tone: indexChip.tone, + }) + } + + const gitLabel = diffStats ? formatGitLabel(diffStats) : null + if ( + gitLabel != null && + widthSize !== 'xs' && + !(widthSize === 'sm' && indexChip != null) + ) { + chips.push({ id: 'git', label: gitLabel, tone: 'secondary' }) + } + + if (modelName && (widthSize === 'lg' || widthSize === 'md')) { + chips.push({ + id: 'model', + label: shortenStatusModelName(modelName, modelMaxChars(widthSize)), + tone: 'muted', + }) + } + + const hasSessionCost = sessionCostCents != null && sessionCostCents > 0 + if (widthSize === 'lg' && hasSessionCost) { + chips.push({ + id: 'cost', + label: formatCostLabel(sessionCostCents), + tone: 'muted', + }) + } + + const allowTimer = + showTimer && elapsedSeconds > 0 && !(widthSize === 'xs' && showStop) + if (allowTimer) { + chips.push({ + id: 'timer', + label: formatElapsedTime(elapsedSeconds), + tone: 'secondary', + }) + } + + const budget = statusBarChipBudget(terminalWidth, showStop) + + while (statusBarClusterWidth(chips) > budget) { + if (removeChip(chips, 'cost')) continue + if (removeChip(chips, 'model')) continue + if (removeChip(chips, 'git')) continue + + const contextChip = chips.find((chip) => chip.id === 'context') + if (contextChip && contextPct != null) { + // Step down one rendered form at a time (token counts first, then the + // bar) so an intermediate label that would still fit is not skipped. + // Compared by rendered width instead of scanning for bar glyphs so + // shortening keeps working if the bar characters change. + const shorter = contextLabelFallbacks(widthSize, contextPct).find( + (label) => stringWidth(label) < stringWidth(contextChip.label), + ) + if (shorter != null) { + contextChip.label = shorter + continue + } + } + + // An idle timer is worth less than the context percent, so it goes here; a + // live timer outranks context and is only given up at the last-resort step + // below. + if (!isActive && removeChip(chips, 'timer')) continue + + // The run is still active here, so context always goes before the timer: + // the elapsed time of a live run matters more than the usage percent, and + // dropping context first also leaves room for an index chip. Unconditional + // on purpose so the priority does not flip when the stop hint is hidden. + if (removeChip(chips, 'context')) continue + + // Last resort: drop the timer even during an active run so a warning or + // error index chip still fits instead of overflowing the width budget. + if (removeChip(chips, 'timer')) continue + + // The index chip is otherwise never dropped, so clamp a long caller-supplied + // label rather than letting it overflow the row. Every other chip has been + // removed by this point, so the whole budget belongs to it. A zero budget + // leaves no room for even one character, and a one-column budget leaves + // room for the ellipsis alone, so both clamps drop the chip instead of + // keeping an information-free label that renderers would draw as stray + // padding or a dangling separator. + const remainingIndexChip = chips.find((chip) => chip.id === 'index') + if (remainingIndexChip) { + const clamped = truncateStatusLabel(remainingIndexChip.label, budget) + if (clamped === '' || clamped === LABEL_ELLIPSIS) { + removeChip(chips, 'index') + continue + } + if (clamped !== remainingIndexChip.label) { + remainingIndexChip.label = clamped + continue + } + } + + // Unreachable, kept as a termination guard: every branch above removes or + // shortens a chip and loops, and truncateStatusLabel only returns its input + // unchanged when the label already fits the budget. + break + } + + // Chips are pushed in render order (context, index, git, model, cost, timer) + // and the overflow loop only removes or shortens them, so the array is + // already ordered here. + return { chips } +} diff --git a/common/src/__tests__/dynamic-agent-template-schema.test.ts b/common/src/__tests__/dynamic-agent-template-schema.test.ts index 9e4c2c65f0..0ff20792c6 100644 --- a/common/src/__tests__/dynamic-agent-template-schema.test.ts +++ b/common/src/__tests__/dynamic-agent-template-schema.test.ts @@ -311,11 +311,11 @@ describe('DynamicAgentDefinitionSchema', () => { expect(result.success).toBe(true) }) - it('should reject template with non-empty spawnableAgents but missing spawn_agents tool', () => { + it('should reject template with non-empty spawnableAgents but missing spawn tools', () => { const template = { ...validBaseTemplate, spawnableAgents: ['researcher', 'file-picker'], // Non-empty spawnableAgents - toolNames: ['end_turn', 'read_files'], // Missing spawn_agents + toolNames: ['end_turn', 'read_files'], // Missing spawn_agents and spawn_agent_inline } const result = DynamicAgentTemplateSchema.safeParse(template) @@ -323,12 +323,12 @@ describe('DynamicAgentDefinitionSchema', () => { if (!result.success) { const spawnAgentsError = result.error.issues.find((issue) => issue.message.includes( - "Non-empty spawnableAgents array requires the 'spawn_agents' tool", + 'Non-empty spawnableAgents array requires a spawn tool', ), ) expect(spawnAgentsError).toBeDefined() expect(spawnAgentsError?.message).toContain( - "Non-empty spawnableAgents array requires the 'spawn_agents' tool", + 'Non-empty spawnableAgents array requires a spawn tool', ) } }) @@ -347,6 +347,17 @@ describe('DynamicAgentDefinitionSchema', () => { } }) + it('should accept template with non-empty spawnableAgents and spawn_agent_inline tool', () => { + const template = { + ...validBaseTemplate, + spawnableAgents: ['researcher', 'file-picker'], + toolNames: ['end_turn', 'spawn_agent_inline'], + } + + const result = DynamicAgentTemplateSchema.safeParse(template) + expect(result.success).toBe(true) + }) + it('should accept generic spawn mode while retaining spawn permissions', () => { const template = { ...validBaseTemplate, diff --git a/common/src/templates/initial-agents-dir/types/tools.ts b/common/src/templates/initial-agents-dir/types/tools.ts index e8ec0e801c..1a1590bfa3 100644 --- a/common/src/templates/initial-agents-dir/types/tools.ts +++ b/common/src/templates/initial-agents-dir/types/tools.ts @@ -251,6 +251,7 @@ export interface EditTransactionParams { occurrenceIndex?: number /** Optional authenticated cap.v3 readCapability copied verbatim from the matching fresh read_files editAnchor. */ basedOnRead?: string + /** 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. */ skipIfMissing?: boolean }[] } @@ -1058,6 +1059,7 @@ export interface StrReplaceParams { occurrenceIndex?: number /** Optional authenticated cap.v3 readCapability copied verbatim from the matching fresh read_files editAnchor. */ basedOnRead?: string + /** 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. */ skipIfMissing?: boolean }[] } diff --git a/common/src/tools/params/tool/edit-transaction.ts b/common/src/tools/params/tool/edit-transaction.ts index b73c415895..741e337bf1 100644 --- a/common/src/tools/params/tool/edit-transaction.ts +++ b/common/src/tools/params/tool/edit-transaction.ts @@ -17,7 +17,12 @@ import { MAX_TRANSACTION_UNIQUE_PATHS, } from '../../../actions' -import { updateFileResultSchema } from './str-replace' +import { + refineSkipIfMissingDeletionOnly, + skipIfMissingCanonicalDescription, + skipIfMissingDescription, + updateFileResultSchema, +} from './str-replace' import type { $ToolParams } from '../../constants' @@ -50,12 +55,7 @@ const replacementSchema = z.preprocess( 'Optional 1-indexed exact occurrence to replace when oldString appears multiple times. Matches str_replace occurrenceIndex semantics and may be combined with basedOnRead to count only within an anchored range.', ), basedOnRead: basedOnReadSchema, - skipIfMissing: z - .boolean() - .optional() - .describe( - '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.', - ), + skipIfMissing: z.boolean().optional().describe(skipIfMissingDescription), }) .strict() .superRefine((replacement, ctx) => { @@ -75,14 +75,7 @@ const replacementSchema = z.preprocess( 'newString is an explicit placeholder, not replacement content. Provide the complete intended text.', }) } - if (replacement.skipIfMissing && replacement.newString !== '') { - ctx.addIssue({ - code: 'custom', - path: ['skipIfMissing'], - message: - 'skipIfMissing is only valid for deletion replacements with an empty newString.', - }) - } + refineSkipIfMissingDeletionOnly(replacement, ctx) }), ) const editBaseSchema = z.object({ @@ -309,9 +302,19 @@ const canonicalReplacementSchema = z allowMultiple: z.boolean().optional().default(false), occurrenceIndex: z.number().int().min(1).optional(), basedOnRead: canonicalBasedOnReadSchema, - skipIfMissing: z.boolean().optional(), + // The input schema enforces `newString === ''` for skipIfMissing via + // superRefine. This provider-declared surface documents AND enforces that + // same constraint so it never advertises a combination the input schema + // rejects. + skipIfMissing: z + .boolean() + .optional() + .describe(skipIfMissingCanonicalDescription), }) .strict() + .superRefine((replacement, ctx) => + refineSkipIfMissingDeletionOnly(replacement, ctx), + ) const canonicalStrReplaceEditSchema = editBaseSchema.extend({ type: z.literal('str_replace'), replacements: z.array(canonicalReplacementSchema).min(1), @@ -496,6 +499,7 @@ export const editTransactionResultSchema = z.union([ 'capability_scope', 'capability_invalid', 'no_match', + 'anchor_scope_mismatch', 'preflight_failed', 'payload_truncated', 'generic', @@ -584,6 +588,7 @@ Important: - Never use prose placeholders such as "[see patch above]" in any edit. Each oldString must contain exact current file content and each newString/content/diff field must contain the complete intended bytes. Placeholder calls are rejected before they can consume a valid read authorization. - The transaction preflights every edit against in-memory file contents first. - If ANY edit fails during preflight, NO files are changed. +- A str_replace replacement may set skipIfMissing on a deletion (empty newString) to make an already-applied cleanup a no-op; a transaction consisting only of such no-ops succeeds with zero file changes. - Every per-file edit is atomic during preflight, including small files. - Structured edits are dispatched deterministically by operation kind; supported operations include insert_text, insert_import, and remove_import. - Select an edit type per operation: str_replace, replace_range, rewrite_symbol, patch, structured, create, delete, move, or write_file. diff --git a/common/src/tools/params/tool/str-replace.ts b/common/src/tools/params/tool/str-replace.ts index 73743e6992..626a2ce3ad 100644 --- a/common/src/tools/params/tool/str-replace.ts +++ b/common/src/tools/params/tool/str-replace.ts @@ -34,6 +34,40 @@ export const updateFileResultSchema = z.union([ const toolName = 'str_replace' const endsAgentStep = false + +/** + * Single source of truth for the model-facing `skipIfMissing` contract. Both + * model-facing surfaces (str_replace and edit_transaction's str_replace edit) + * import this so the two descriptions cannot drift. + */ +export const skipIfMissingDescription = + '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.' + +/** Provider-surface note: both schemas reject a non-empty newString. */ +export const skipIfMissingCanonicalDescription = `${skipIfMissingDescription} Only valid when newString is empty; both the input and provider schemas reject any other combination.` + +/** + * Single source of truth for the `skipIfMissing` deletion-only CHECK, mirroring + * `skipIfMissingDescription` for the text. All four model-facing surfaces + * (str_replace's input + provider schemas, edit_transaction's replacement + + * canonical replacement schemas) call this from their superRefine so neither the + * condition nor its message can drift. + */ +export function refineSkipIfMissingDeletionOnly( + replacement: { newString: string; skipIfMissing?: boolean | undefined }, + ctx: { + addIssue(issue: { code: 'custom'; path: string[]; message: string }): void + }, +): void { + if (replacement.skipIfMissing && replacement.newString !== '') { + ctx.addIssue({ + code: 'custom', + path: ['skipIfMissing'], + message: + 'skipIfMissing is only valid for deletion replacements with an empty newString.', + }) + } +} const inputSchema = z .object({ path: z @@ -87,9 +121,7 @@ const inputSchema = z skipIfMissing: z .boolean() .optional() - .describe( - 'For deletion replacements only: treat an already-missing oldString as a successful idempotent no-op.', - ), + .describe(skipIfMissingDescription), }) .superRefine((replacement, ctx) => { if (isObviousEditPlaceholder(replacement.oldString)) { @@ -108,17 +140,7 @@ const inputSchema = z 'newString is an explicit placeholder, not replacement content. Provide the complete intended text.', }) } - if ( - replacement.skipIfMissing && - replacement.newString !== '' - ) { - ctx.addIssue({ - code: 'custom', - path: ['skipIfMissing'], - message: - 'skipIfMissing is only valid for deletion replacements with an empty newString.', - }) - } + refineSkipIfMissingDeletionOnly(replacement, ctx) }), ) .describe('Pair of oldString and newString values.'), @@ -139,8 +161,18 @@ const providerInputSchema = z.object({ allowMultiple: z.boolean().optional().default(false), occurrenceIndex: z.number().int().min(1).optional(), basedOnRead: canonicalBasedOnReadSchema, - skipIfMissing: z.boolean().optional(), - }), + skipIfMissing: z + .boolean() + .optional() + .describe(skipIfMissingCanonicalDescription), + }) + // The declared provider surface must reject exactly what the input + // schema rejects, so it never advertises skipIfMissing with a non-empty + // newString as a valid combination. Both go through the same shared + // refinement. + .superRefine((replacement, ctx) => + refineSkipIfMissingDeletionOnly(replacement, ctx), + ), ) .min(1), }) diff --git a/common/src/types/dynamic-agent-template.ts b/common/src/types/dynamic-agent-template.ts index adce4f3024..8be7585aa6 100644 --- a/common/src/types/dynamic-agent-template.ts +++ b/common/src/types/dynamic-agent-template.ts @@ -306,10 +306,12 @@ export const DynamicAgentTemplateSchema = DynamicAgentDefinitionSchema.extend({ // ) .refine( (data) => { - // If spawnableAgents array is non-empty, 'spawn_agents' tool must be included + // If spawnableAgents array is non-empty, a spawn tool must be included. + // Production parents often use spawn_agent_inline rather than spawn_agents. if ( data.spawnableAgents.length > 0 && - !data.toolNames.includes('spawn_agents') + !data.toolNames.includes('spawn_agents') && + !data.toolNames.includes('spawn_agent_inline') ) { return false } @@ -317,7 +319,7 @@ export const DynamicAgentTemplateSchema = DynamicAgentDefinitionSchema.extend({ }, { message: - "Non-empty spawnableAgents array requires the 'spawn_agents' tool. Add 'spawn_agents' to toolNames or remove spawnableAgents.", + "Non-empty spawnableAgents array requires a spawn tool. Add 'spawn_agents' or 'spawn_agent_inline' to toolNames or remove spawnableAgents.", path: ['toolNames'], }, ) diff --git a/docs/configuration.md b/docs/configuration.md index add2eaea11..5ed3c69676 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -341,20 +341,16 @@ gating, and tool-result lifecycle trimming). There is **no new JSON config field** for these systems yet — the behavior is code-default and always on. - **`progressivePromptDisclosure`** is an SDK/agent option on `createBase2`, - not a JSON config key. It is ON by default when the option is omitted. The - `OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE` env canary (`1`/`true`/`yes`/`on` - → true) still forces it on when the option is omitted, and explicit - `true`/`false` on the option always wins over both the default and the - canary — pass `progressivePromptDisclosure: false` to restore the - pre-disclosure prompt assembly. When enabled, verbose advisory prompt - sections are replaced by `read_files` pointers to `agents/guides/*.md`. -- **`progressiveToolDisclosure`** is an SDK/agent option on `createBase2`, not - a JSON config key. When the option is omitted, it defaults from the - `OPENBUFF_PROGRESSIVE_TOOL_DISCLOSURE` env canary (`1`/`true`/`yes`/`on` → - true; otherwise false). Explicit `true`/`false` always wins over the env. - When enabled with no unlocked tiers, model-visible tools are CORE only - (mode gates still apply); full surface remains the default when off. Opt-in - only; production stays off without the option or canary. + not a JSON config key. It defaults to `true` when the option is omitted, and + an explicit `true`/`false` on the option always wins over that default. + There is no env var for it — pass `progressivePromptDisclosure: false` to + restore the pre-disclosure prompt assembly. When enabled, verbose advisory + prompt sections are replaced by `read_files` pointers to + `agents/guides/*.md`. +- **`unlockedTiers`** is an SDK/agent option on `createBase2`, not a JSON + config key. It is the only control that narrows the model-visible tool + surface: every non-core tier is unlocked by default, and passing `[]` ships a + CORE-only surface (mode gates still apply). There is no env var for it. - **`maxReviewerRepairRounds`** is an SDK/agent option on `createBase2` (also not a JSON config key). Default **unlimited** (progress-gated: no-progress fingerprint and incomplete-receipt exits). Optional positive integer cap, @@ -388,10 +384,11 @@ field** for these systems yet — the behavior is code-default and always on. [Indexing and retrieval](#indexing-and-retrieval) above. All reductions and gates are on by default, including progressive prompt -disclosure (opt out with an explicit `progressivePromptDisclosure: false`); -progressive tool disclosure remains the opt-in (via the agent option / env -canary above). Gate repair loops default to unlimited/progress-gated; optional -env or createBase2 caps remain available. +disclosure; opt out of it with an explicit +`progressivePromptDisclosure: false` option, since that flag has no env var. +The full tool surface is likewise on by default; narrow it with +`unlockedTiers`. Gate repair loops default to unlimited/progress-gated; +optional env or createBase2 caps remain available. ## Merge semantics diff --git a/docs/environment-variables.md b/docs/environment-variables.md index bbe966d573..3156dc482b 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -41,23 +41,13 @@ Document only environment variables that are implemented in code. During the for `CODEBUFF_API_KEY` functions as a runtime fallback (`OPENBUFF_API_KEY ?? CODEBUFF_API_KEY` in `sdk/src/env.ts`, Openbuff primary). `CODEBUFF_CHATGPT_OAUTH_TOKEN` also has an `OPENBUFF_*` alias but with reversed precedence (legacy name primary). `OPENBUFF_GIT_BASH_PATH` takes precedence over the legacy `CODEBUFF_GIT_BASH_PATH` fallback. Context-budget and proactive-retrieval behaviors remain code-default (no new -env vars). Progressive prompt/tool disclosure and gate repair budgets each have -optional env canaries: - -- `OPENBUFF_PROGRESSIVE_PROMPT_DISCLOSURE` — `progressivePromptDisclosure` - is ON by default: when the option is omitted, `createBase2` enables - progressive prompt disclosure even without any env var set. The canary - (`1`, `true`, `yes`, or `on`, case-insensitive) still forces it on when the - option is omitted. Explicit option values always win: - `progressivePromptDisclosure: false` turns disclosure off (the - pre-disclosure prompt surface), and explicit `true` wins over the env - canary. -- `OPENBUFF_PROGRESSIVE_TOOL_DISCLOSURE` — when set to `1`, `true`, `yes`, or - `on` (case-insensitive), `createBase2` defaults `progressiveToolDisclosure` - to `true` if the option is omitted. Explicit `true`/`false` on the agent - option always wins over the env canary. When enabled with no unlocked tiers, - the model-visible tool surface is CORE-only (mode gates still apply). - Production stays off unless the canary or option is set. +env vars). Progressive prompt disclosure has no env var at all: +`progressivePromptDisclosure` is a `createBase2` option (not a JSON config +key), defaults to `true` when omitted, and an explicit `true`/`false` on the +option is the only way to change it. The tool surface likewise has no env var: +narrow it with the `createBase2` `unlockedTiers` option. Only the +gate repair budgets have optional env canaries: + - `OPENBUFF_MAX_REVIEWER_REPAIR_ROUNDS` — optional positive integer string that caps the reviewer→repair→re-review loop (max `20`). Unset or invalid → **unlimited** (progress-gated). Explicit diff --git a/packages/agent-runtime/src/__tests__/process-str-replace.test.ts b/packages/agent-runtime/src/__tests__/process-str-replace.test.ts index a18f72af50..e2feb6c6dc 100644 --- a/packages/agent-runtime/src/__tests__/process-str-replace.test.ts +++ b/packages/agent-runtime/src/__tests__/process-str-replace.test.ts @@ -1538,6 +1538,241 @@ function test3() { } }) + it('reports an anchored scope mismatch when the fresh basedOnRead window does not contain oldString', async () => { + // Regression: a FRESH, hash-valid basedOnRead whose window does not contain + // oldString, while the oldString still EXISTS elsewhere in the file, must + // not be reported as a whole-file "not an exact contiguous match" (which + // wrongly claims the text changed/was removed and loops the model into + // re-reading the same window). + const lines = Array.from({ length: 1_001 }, (_, index) => + index === 1_000 ? 'export const target = 1' : `const filler${index} = ${index};`, + ) + const initialContent = lines.join('\n') + // Lines 1-10 are fresh, but the target lives at line 1001. + const windowContent = lines.slice(0, 10).join('\n') + const token = readCapability({ + path: 'large.ts', + startLine: 1, + endLine: 10, + content: windowContent, + }) + + const result = await processStrReplace({ + path: 'large.ts', + readCapabilityScope: readScope('large.ts'), + replacements: [ + { + oldString: 'export const target = 1', + newString: 'export const target = 2', + allowMultiple: false, + basedOnRead: token, + }, + ], + initialContentPromise: Promise.resolve(initialContent), + logger, + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain( + 'Anchored str_replace scope mismatch for large.ts', + ) + expect(result.error).toContain('covers lines 1-10') + expect(result.error).toContain( + 'oldString currently occurs at line(s): 1001-1001', + ) + // The classification is a structured field; the sentinel token must never + // appear in model-facing prose (a copied oldString could contain it). + expect(result.failureKind).toBe('anchor_scope_mismatch') + expect(result.error).not.toContain('anchor_scope_mismatch') + expect(result.error).toContain( + 'Do not re-read the same window and resend the identical oldString', + ) + // The generic "text was changed/removed, re-read and copy it" guidance is + // suppressed: it would return the identical oldString forever. + expect(result.error).not.toContain(recoveryGuidance) + // The gate fires before tryMatchOldStr, so no fake no-match diagnostic. + expect(result.error).not.toContain('not an exact contiguous match') + } + expect('content' in result).toBe(false) + }) + + it('still reports the ordinary no-match diagnostic for a genuine anchored miss', async () => { + const lines = Array.from({ length: 1_001 }, (_, index) => + index === 1_000 ? 'export const target = 1' : `const filler${index} = ${index};`, + ) + const initialContent = lines.join('\n') + const windowContent = lines.slice(0, 10).join('\n') + const token = readCapability({ + path: 'large.ts', + startLine: 1, + endLine: 10, + content: windowContent, + }) + + const result = await processStrReplace({ + path: 'large.ts', + readCapabilityScope: readScope('large.ts'), + replacements: [ + { + // Present nowhere in the file, so this is a real no-match. + oldString: 'export const absentEverywhere = 42', + newString: 'export const absentEverywhere = 43', + allowMultiple: false, + basedOnRead: token, + }, + ], + initialContentPromise: Promise.resolve(initialContent), + logger, + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('not an exact contiguous match') + expect(result.error).not.toContain('anchor_scope_mismatch') + expect(result.failureKind).toBeUndefined() + } + }) + + it('keeps the generic recovery guidance for a co-failing no-match in a mixed atomic batch', async () => { + // A single anchored scope mismatch must not suppress the recovery guidance + // that the genuine no-match beside it still needs, and a mixed batch must + // not be classified as a scope mismatch (which would narrow invalidation). + const lines = Array.from({ length: 1_001 }, (_, index) => + index === 1_000 ? 'export const target = 1' : `const filler${index} = ${index};`, + ) + const initialContent = lines.join('\n') + const windowContent = lines.slice(0, 10).join('\n') + const token = readCapability({ + path: 'large.ts', + startLine: 1, + endLine: 10, + content: windowContent, + }) + + const result = await processStrReplace({ + path: 'large.ts', + readCapabilityScope: readScope('large.ts'), + replacements: [ + { + // Anchored scope mismatch: present in the file, outside the window. + oldString: 'export const target = 1', + newString: 'export const target = 2', + allowMultiple: false, + basedOnRead: token, + }, + { + // Genuine no-match: present nowhere in the file. + oldString: 'export const absentEverywhere = 42', + newString: 'export const absentEverywhere = 43', + allowMultiple: false, + basedOnRead: token, + }, + ], + initialContentPromise: Promise.resolve(initialContent), + logger, + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('Anchored str_replace scope mismatch') + expect(result.error).toContain('not an exact contiguous match') + expect(result.error).toContain(recoveryGuidance) + expect(result.failureKind).toBeUndefined() + } + }) + + it('reports absolute candidate line numbers for an anchored no-match', async () => { + const lines = Array.from({ length: 1_001 }, (_, index) => + index === 500 + ? 'export function computeInvoiceTotal(order: Order) {' + : index === 501 + ? ' const subtotal = order.subtotalCents' + : index === 502 + ? ' const shipping = order.shippingCents' + : index === 503 + ? ' return subtotal + shipping' + : index === 504 + ? '}' + : `const filler${index} = ${index};`, + ) + const initialContent = lines.join('\n') + const rangeContent = lines.slice(500, 505).join('\n') + const token = readCapability({ + path: 'large.ts', + startLine: 501, + endLine: 505, + content: rangeContent, + }) + // Drifted enough to stay well below the 0.92 auto-correct threshold and to + // declare a different top-level symbol, so no auto-correction can apply. + const oldStr = [ + 'export function computeInvoiceSum(order: Order) {', + ' const subtotal = order.subTotal', + ' const shipping = order.shipping', + ' return subTotal + shipping', + '}', + ].join('\n') + + const result = await processStrReplace({ + path: 'large.ts', + readCapabilityScope: readScope('large.ts'), + replacements: [ + { + oldString: oldStr, + newString: 'export function computeInvoiceTotal(order: Order) {\n return 0\n}', + allowMultiple: false, + basedOnRead: token, + }, + ], + initialContentPromise: Promise.resolve(initialContent), + logger, + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain( + 'not an exact contiguous match of the anchored range lines 501-505 of the current file', + ) + // Candidate lines are computed over the anchored window slice, so they + // must be shifted back to absolute file lines before being reported. + const candidates = [ + ...result.error.matchAll(/Candidate \d+: lines (\d+)-(\d+)/g), + ] + if (candidates.length > 0) { + for (const candidate of candidates) { + expect(Number(candidate[1])).toBeGreaterThanOrEqual(501) + } + } + } + }) + + it('keeps unanchored no-match wording and generic recovery guidance unchanged', async () => { + const initialContent = 'const first = 1;\nconst second = 2;\n' + + const result = await processStrReplace({ + path: 'small.ts', + readCapabilityScope: readScope('small.ts'), + replacements: [ + { + oldString: 'const missingEntirely = 3;', + newString: 'const missingEntirely = 4;', + allowMultiple: false, + }, + ], + initialContentPromise: Promise.resolve(initialContent), + logger, + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain( + 'not an exact contiguous match of the current file', + ) + expect(result.error).toContain(recoveryGuidance) + } + }) + it('accepts a strict cap.v3 token only for its bound project, path, and run', async () => { const initialContent = 'const target = 1;\n' const scope = { @@ -1602,6 +1837,9 @@ function test3() { expect(result).toHaveProperty('error') if ('error' in result) { + // Structured field, not prose: the edit_transaction handler revokes read + // authorization off failureKind.startsWith('capability'). + expect(result.failureKind).toBe('capability_scope') expect(result.error).toContain('different project, path, or agent run') expect(result.error).toContain('Cross-path and cross-run capability replay') expect(result.error).not.toContain('may refer to content that changed') @@ -2136,6 +2374,449 @@ function test3() { } }) + it('[ABI-M07] reports a skipIfMissing deletion missing from the anchored window as a no-op skip, not an anchored scope mismatch', async () => { + const lines = Array.from({ length: 1_001 }, (_, index) => + index === 100 + ? 'console.log("debug")' + : index === 500 + ? 'const target = 1;' + : `const filler${index} = ${index};`, + ) + const initialContent = lines.join('\n') + const targetRange = lines.slice(500, 501).join('\n') + + const result = await processStrReplace({ + path: 'large.ts', + readCapabilityScope: readScope('large.ts'), + replacements: [ + { + // Present at line 101, i.e. outside the anchored window, so the + // scope-mismatch gate would fire if it ran before the no-op skip. + oldString: 'console.log("debug")', + newString: '', + allowMultiple: false, + skipIfMissing: true, + basedOnRead: readCapability({ + path: 'large.ts', + startLine: 501, + endLine: 501, + content: targetRange, + }), + }, + ], + initialContentPromise: Promise.resolve(initialContent), + logger, + }) + + expect('content' in result).toBe(true) + if ('content' in result) { + expect(result.content).toContain('console.log("debug")') + expect(result.failedReplacementCount).toBe(0) + const messageText = result.messages.join('\n') + expect(messageText).toContain('within the anchored range') + expect(messageText).not.toContain('scope mismatch') + } + }) + + it('[ABI-M07] skips an already-applied skipIfMissing deletion on a large file without basedOnRead', async () => { + const lines = Array.from({ length: 1_001 }, (_, index) => + index === 500 ? 'const target = 1;' : `const filler${index} = ${index};`, + ) + const initialContent = lines.join('\n') + + const result = await processStrReplace({ + path: 'large.ts', + readCapabilityScope: readScope('large.ts'), + replacements: [ + { + // Already deleted: absent from the WHOLE file, which needs no anchor + // to prove, so this must skip rather than fall into the large-file + // deterministic-fallback block and abort the atomic batch. + oldString: 'console.log("already removed")\n', + newString: '', + allowMultiple: false, + skipIfMissing: true, + }, + ], + initialContentPromise: Promise.resolve(initialContent), + logger, + }) + + expect('error' in result).toBe(false) + expect('content' in result).toBe(true) + if ('content' in result) { + expect(result.content).toBe(initialContent) + expect(result.patch).toBe('') + expect(result.failedReplacementCount).toBe(0) + expect(result.hadNoOpSkip).toBe(true) + const messageText = result.messages.join('\n') + expect(messageText).toContain( + 'Skipped already-applied str_replace deletion', + ) + expect(messageText).not.toContain('Large-file edit blocked') + expect(messageText).not.toContain('within the anchored range') + } + }) + + it('[ABI-M07] treats a partially-applied skipIfMissing deletion with occurrenceIndex as a no-op skip', async () => { + const initialContent = [ + 'const keep = 1;', + 'console.log("debug")', + 'const keep = 2;', + ].join('\n') + + const result = await processStrReplace({ + path: 'small.ts', + readCapabilityScope: readScope('small.ts'), + atomic: true, + replacements: [ + { + // Only one occurrence is left, so the earlier occurrences of this + // cleanup were already applied. That must skip instead of hard-failing + // the atomic batch with 'only N exact occurrence(s) ... exist'. + oldString: 'console.log("debug")', + newString: '', + allowMultiple: false, + occurrenceIndex: 3, + skipIfMissing: true, + }, + ], + initialContentPromise: Promise.resolve(initialContent), + logger, + }) + + expect('error' in result).toBe(false) + expect('content' in result).toBe(true) + if ('content' in result) { + expect(result.content).toBe(initialContent) + expect(result.patch).toBe('') + expect(result.failedReplacementCount).toBe(0) + expect(result.hadNoOpSkip).toBe(true) + const messageText = result.messages.join('\n') + expect(messageText).toContain( + 'Skipped already-applied str_replace deletion', + ) + expect(messageText).toContain('occurrenceIndex 3') + expect(messageText).not.toContain( + 'only 1 exact occurrence(s) of the oldString exist', + ) + } + }) + + it('[ABI-M07] does not suppress a co-present real change in a mixed skipIfMissing batch', async () => { + // A skip must never swallow a replacement that really applies: the batch + // reports a real patch, the skip message, and NO hadNoOpSkip (the all-skip + // short-circuit flag) so no consumer discards the applied content. + const initialContent = ['const keep = 1;', 'const target = 1;'].join('\n') + + const result = await processStrReplace({ + path: 'small.ts', + readCapabilityScope: readScope('small.ts'), + atomic: true, + replacements: [ + { + oldString: 'console.log("already removed")\n', + newString: '', + allowMultiple: false, + skipIfMissing: true, + }, + { + oldString: 'const target = 1;', + newString: 'const target = 2;', + allowMultiple: false, + }, + ], + initialContentPromise: Promise.resolve(initialContent), + logger, + }) + + expect('error' in result).toBe(false) + expect('content' in result).toBe(true) + if ('content' in result) { + expect(result.content).toContain('const target = 2;') + expect(result.patch).not.toBe('') + expect(result.failedReplacementCount).toBe(0) + expect(result.hadNoOpSkip).toBeUndefined() + expect(result.messages.join('\n')).toContain( + 'Skipped already-applied str_replace deletion', + ) + } + }) + + it('[ABI-M07] skips a skipIfMissing deletion replaying a stale basedOnRead when oldString is absent from the whole file', async () => { + const initialContent = ['const keep = 1;', 'const keep = 2;'].join('\n') + + const result = await processStrReplace({ + path: 'small.ts', + readCapabilityScope: readScope('small.ts'), + atomic: true, + replacements: [ + { + // The idempotent cleanup retry replays its ORIGINAL anchor, which is + // necessarily stale now that the deletion already landed. A capability + // window is a subset of the file, so whole-file absence proves window + // absence: this must skip instead of failing the scoped-stale gate. + oldString: 'console.log("already removed")\n', + newString: '', + allowMultiple: false, + skipIfMissing: true, + basedOnRead: readCapability({ + path: 'small.ts', + startLine: 1, + endLine: 1, + content: 'console.log("already removed")', + }), + }, + ], + initialContentPromise: Promise.resolve(initialContent), + logger, + }) + + expect('error' in result).toBe(false) + if ('error' in result) { + expect(result.error).not.toContain('Scoped str_replace blocked') + } + expect('content' in result).toBe(true) + if ('content' in result) { + expect(result.content).toBe(initialContent) + expect(result.patch).toBe('') + expect(result.failedReplacementCount).toBe(0) + expect(result.hadNoOpSkip).toBe(true) + const messageText = result.messages.join('\n') + expect(messageText).toContain( + 'Skipped already-applied str_replace deletion', + ) + expect(messageText).not.toContain('Scoped str_replace blocked') + expect(messageText).not.toContain('within the anchored range') + } + }) + + it('[ABI-M07] skips a stale-anchored skipIfMissing occurrenceIndex deletion when the whole file has fewer occurrences', async () => { + const initialContent = [ + 'const keep = 1;', + 'console.log("debug")', + 'const keep = 2;', + ].join('\n') + + const result = await processStrReplace({ + path: 'small.ts', + readCapabilityScope: readScope('small.ts'), + atomic: true, + replacements: [ + { + // Stale anchor plus occurrenceIndex: the anchored window is a subset + // of the file, so a whole-file remaining count below occurrenceIndex + // proves the anchored count is below it too. Skip, do not report the + // stale/invalid-anchor failure. + oldString: 'console.log("debug")', + newString: '', + allowMultiple: false, + occurrenceIndex: 3, + skipIfMissing: true, + basedOnRead: readCapability({ + path: 'small.ts', + startLine: 2, + endLine: 2, + content: 'console.log("debug") // stale window content', + }), + }, + ], + initialContentPromise: Promise.resolve(initialContent), + logger, + }) + + expect('error' in result).toBe(false) + expect('content' in result).toBe(true) + if ('content' in result) { + expect(result.content).toBe(initialContent) + expect(result.patch).toBe('') + expect(result.failedReplacementCount).toBe(0) + expect(result.hadNoOpSkip).toBe(true) + const messageText = result.messages.join('\n') + expect(messageText).toContain( + 'occurrenceIndex 3 is treated as already applied', + ) + // No fresh capability was proven for this path, so the skip degrades to + // the boolean 'fewer than N remain' phrasing instead of disclosing the + // exact remaining occurrence count. + expect(messageText).toContain( + 'fewer than 3 exact occurrence(s) of the oldString remain', + ) + expect(messageText).not.toContain('only 1 exact occurrence(s)') + expect(messageText).not.toContain( + 'the supplied basedOnRead range is stale or invalid', + ) + expect(messageText).not.toContain('within the anchored range') + } + }) + + it('[ABI-M07] still fails a stale-anchored skipIfMissing deletion whose oldString is still present', async () => { + // Inverse guard for the reorder: whole-file absence is the ONLY thing the + // unanchored skip may conclude. A still-present oldString keeps hitting the + // stale-anchor gate exactly as before. + const initialContent = [ + 'console.log("debug")', + 'const keep = 1;', + 'console.log("debug")', + ].join('\n') + + const result = await processStrReplace({ + path: 'small.ts', + readCapabilityScope: readScope('small.ts'), + atomic: true, + replacements: [ + { + oldString: 'console.log("debug")', + newString: '', + allowMultiple: false, + skipIfMissing: true, + basedOnRead: readCapability({ + path: 'small.ts', + startLine: 1, + endLine: 1, + content: 'console.log("debug") // stale window content', + }), + }, + ], + initialContentPromise: Promise.resolve(initialContent), + logger, + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('Scoped str_replace blocked') + expect(result.error).not.toContain( + 'Skipped already-applied str_replace deletion', + ) + } + }) + + it('[ABI-M07] still fails a stale-anchored skipIfMissing occurrenceIndex deletion when enough occurrences remain', async () => { + const initialContent = [ + 'console.log("debug")', + 'const keep = 1;', + 'console.log("debug")', + ].join('\n') + + const result = await processStrReplace({ + path: 'small.ts', + readCapabilityScope: readScope('small.ts'), + atomic: true, + replacements: [ + { + oldString: 'console.log("debug")', + newString: '', + allowMultiple: false, + occurrenceIndex: 2, + skipIfMissing: true, + basedOnRead: readCapability({ + path: 'small.ts', + startLine: 1, + endLine: 1, + content: 'console.log("debug") // stale window content', + }), + }, + ], + initialContentPromise: Promise.resolve(initialContent), + logger, + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain( + 'the supplied basedOnRead range is stale or invalid', + ) + expect(result.error).not.toContain( + 'Skipped already-applied str_replace deletion', + ) + } + }) + + it('[ABI-M07] resolves a strict-read all-skip deletion batch as a successful no-op', async () => { + // The no-op skips run BEFORE the requireFreshReadCapability gate, so a + // strict-mode cleanup retry whose work is already applied succeeds without + // mutating the file instead of being blocked for a missing fresh anchor. + const initialContent = [ + 'const keep = 1;', + 'console.log("debug")', + 'const keep = 2;', + ].join('\n') + + const result = await processStrReplace({ + path: 'small.ts', + readCapabilityScope: readScope('small.ts'), + requireFreshReadCapability: true, + atomic: true, + replacements: [ + { + oldString: 'console.log("already removed")\n', + newString: '', + allowMultiple: false, + skipIfMissing: true, + }, + { + oldString: 'console.log("debug")', + newString: '', + allowMultiple: false, + occurrenceIndex: 3, + skipIfMissing: true, + }, + ], + initialContentPromise: Promise.resolve(initialContent), + logger, + }) + + expect('error' in result).toBe(false) + expect('content' in result).toBe(true) + if ('content' in result) { + expect(result.content).toBe(initialContent) + expect(result.patch).toBe('') + expect(result.failedReplacementCount).toBe(0) + expect(result.hadNoOpSkip).toBe(true) + const messageText = result.messages.join('\n') + expect(messageText).toContain( + 'Skipped already-applied str_replace deletion', + ) + expect(messageText).toContain( + 'occurrenceIndex 3 is treated as already applied', + ) + expect(messageText).not.toContain('Strict read-before-edit blocked') + } + }) + + it('[ABI-M07] still blocks a strict-read skipIfMissing deletion whose oldString is still present', async () => { + // Inverse guard: the strict-read bypass only covers provable no-ops. A + // still-present oldString with no fresh capability must keep failing. + const initialContent = ['const keep = 1;', 'console.log("debug")'].join( + '\n', + ) + + const result = await processStrReplace({ + path: 'small.ts', + readCapabilityScope: readScope('small.ts'), + requireFreshReadCapability: true, + atomic: true, + replacements: [ + { + oldString: 'console.log("debug")', + newString: '', + allowMultiple: false, + skipIfMissing: true, + }, + ], + initialContentPromise: Promise.resolve(initialContent), + logger, + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('Strict read-before-edit blocked') + expect(result.error).not.toContain( + 'Skipped already-applied str_replace deletion', + ) + } + }) + it('should accept multi-line CRLF range hashes from read_files', async () => { const lines = Array.from({ length: 1_001 }, (_, index) => index === 500 @@ -2319,12 +3000,24 @@ function test3() { logger, }) + // The anchored window is still honored (the out-of-window match is NOT + // applied), but the failure is now reported as a scope mismatch instead of + // a false whole-file "not an exact contiguous match": the text exists, just + // outside the supplied capability range. expect('error' in result).toBe(true) if ('error' in result) { - expect(result.error).toContain('is not an exact contiguous match') + expect(result.error).toContain( + 'Anchored str_replace scope mismatch for small.ts', + ) + expect(result.error).toContain('covers lines 1-1') + expect(result.error).toContain('oldString currently occurs at line(s): 3-3') + expect(result.failureKind).toBe('anchor_scope_mismatch') + expect(result.error).not.toContain('anchor_scope_mismatch') + expect(result.error).not.toContain('is not an exact contiguous match') } }) + describe('successful edit authority', () => { it('does not mint pre-confirmation authority after a scoped large-file edit', async () => { const lines = Array.from({ length: 1_001 }, (_, index) => diff --git a/packages/agent-runtime/src/__tests__/read-files-edit-state.test.ts b/packages/agent-runtime/src/__tests__/read-files-edit-state.test.ts index 91f0790d95..1748982582 100644 --- a/packages/agent-runtime/src/__tests__/read-files-edit-state.test.ts +++ b/packages/agent-runtime/src/__tests__/read-files-edit-state.test.ts @@ -17,6 +17,7 @@ import { handleWriteFile, normalizeToolPath, } from '../tools/handlers/tool/write-file' +import { processEditTransaction } from '../process-edit-transaction' import { encodeReadCapabilityToken, getContentHash, @@ -36,6 +37,7 @@ import type { } from '@codebuff/common/types/contracts/agent-runtime' import type { Logger } from '@codebuff/common/types/contracts/logger' import type { AgentTemplate } from '../templates/types' +import { strReplaceParams } from '@codebuff/common/tools/params/tool/str-replace' const logger: Logger = { debug: () => {}, @@ -1156,6 +1158,124 @@ describe('read_files edit-state recovery', () => { expect(fileProcessingState.promisesByPath[path]).toBeUndefined() }) + it('returns skip messages without calling the client when every str_replace replacement is an already-applied deletion', async () => { + const path = 'src/idempotent.ts' + const diskContent = 'export const value = 1\n' + const fileProcessingState = createFileProcessingState() + const clientToolCalls: any[] = [] + + const result = await handleStrReplace({ ...defaultTestHandlerAuthority, + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'all-skip-replace', + toolName: 'str_replace', + input: { + path, + replacements: [ + { + oldString: 'console.log("debug")\n', + newString: '', + allowMultiple: false, + skipIfMissing: true, + }, + ], + }, + }, + fileProcessingState, + logger, + requestOptionalFile: async ({ filePath }: { filePath: string }) => + filePath === path ? diskContent : null, + requestClientToolCall: async (toolCall: any) => { + clientToolCalls.push(toolCall) + return [] + }, + writeToClient: () => {}, + } as any) + + // A pure no-op must never issue a client write (an empty patch would be + // sent as a whole-file write of the unchanged content). + expect(clientToolCalls).toHaveLength(0) + expect(result.output[0]?.type).toBe('json') + if (result.output[0]?.type === 'json') { + const value = result.output[0].value as { + file?: string + message?: string + errorMessage?: string + patch?: string + } + expect(value.file).toBe(path) + expect(value.errorMessage).toBeUndefined() + expect(value.patch).toBeUndefined() + expect(value.message).toContain( + 'Skipped already-applied str_replace deletion', + ) + } + expect( + fileProcessingState.failedEditRequiresReadByPath[path], + ).toBeUndefined() + }) + + it('calls the client with the applied content when a str_replace batch mixes an already-applied deletion with a real replacement', async () => { + const path = 'src/mixed-idempotent.ts' + const diskContent = 'export const value = 1\n' + const appliedContent = 'export const value = 2\n' + const fileProcessingState = createFileProcessingState() + const clientToolCalls: any[] = [] + + const result = await handleStrReplace({ ...defaultTestHandlerAuthority, + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'mixed-skip-replace', + toolName: 'str_replace', + input: { + path, + replacements: [ + { + // Already applied: the oldString is absent, so this is a no-op. + oldString: 'console.log("debug")\n', + newString: '', + allowMultiple: false, + skipIfMissing: true, + }, + { + // Really applies: the co-present skip must not discard it. + oldString: 'export const value = 1', + newString: 'export const value = 2', + allowMultiple: false, + }, + ], + }, + }, + fileProcessingState, + logger, + requestOptionalFile: async ({ filePath }: { filePath: string }) => + filePath === path ? diskContent : null, + requestClientToolCall: async (toolCall: any) => { + clientToolCalls.push(toolCall) + return confirmedMutationOutput(toolCall, { + [path]: appliedContent, + }) + }, + writeToClient: () => {}, + } as any) + + // The co-present real change must reach the client with its applied content. + expect(clientToolCalls).toHaveLength(1) + expect(clientToolCalls[0].input.path).toBe(path) + expect(clientToolCalls[0].input.content).toContain( + '+export const value = 2', + ) + expect(result.output[0]?.type).toBe('json') + if (result.output[0]?.type === 'json') { + const value = result.output[0].value as { + errorMessage?: string + message?: string + } + expect(value.errorMessage).toBeUndefined() + expect(value.message ?? '').not.toContain('No file changes were applied') + } + }) + it('preserves authorization when the client explicitly rejects str_replace without applying', async () => { const path = 'src/rejected.ts' const diskContent = 'export const value = 1\n' @@ -7332,6 +7452,126 @@ describe('read_files edit-state recovery', () => { expect(String(value.errorMessage)).toContain(pathB) } }) + + it('anchored scope mismatch narrows invalidation to the failing path only', async () => { + // A fresh-but-wrong-window basedOnRead is a per-path targeting mistake: + // no file changed and only the offending path's read scope is wrong, so + // the peer target must keep its read authorization and the prose must not + // demand fresh reads for every transaction target. + const pathA = 'src/anchor-scope-a.ts' + const pathB = 'src/anchor-scope-b.ts' + const contentA = + ['export const first = 1', 'export const second = 2', 'export const target = 3'].join( + '\n', + ) + '\n' + const contentB = 'export const peer = 1\n' + const runId = 'anchor-scope-mismatch-narrow-run' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + fileProcessingState.readAuthorizationsByPath = { + [pathA]: true, + [pathB]: true, + } + fileProcessingState.readAuthorizationHashesByPath = { + [pathA]: getContentHash(contentA), + [pathB]: getContentHash(contentB), + } + // Fresh capability covering ONLY line 1 of pathA, while the oldString + // lives on line 3. + const wrongWindowCapability = encodeReadCapabilityToken({ + startLine: 1, + endLine: 1, + hash: getContentHash('export const first = 1'), + scope: { projectId: mockFileContext.projectRoot, path: pathA, runId }, + }) + const wholeFileCapabilityB = encodeReadCapabilityToken({ + startLine: 1, + endLine: contentB.split('\n').length, + hash: getContentHash(contentB), + scope: { projectId: mockFileContext.projectRoot, path: pathB, runId }, + }) + + let clientCalls = 0 + const result = await handleEditTransaction({ + ...defaultTestHandlerAuthority, + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'anchor-scope-mismatch-narrow-tx', + toolName: 'edit_transaction', + input: { + edits: [ + { + type: 'str_replace', + path: pathA, + replacements: [ + { + oldString: 'export const target = 3', + newString: 'export const target = 4', + allowMultiple: false, + basedOnRead: wrongWindowCapability, + }, + ], + }, + { + type: 'str_replace', + path: pathB, + replacements: [ + { + oldString: 'export const peer = 1', + newString: 'export const peer = 2', + allowMultiple: false, + basedOnRead: wholeFileCapabilityB, + }, + ], + }, + ], + }, + }, + fileProcessingState, + fileContext: mockFileContext, + runId, + logger, + requestOptionalFile: async ({ filePath }: { filePath: string }) => { + if (filePath === pathA) return contentA + if (filePath === pathB) return contentB + return null + }, + requestClientToolCall: async () => { + clientCalls += 1 + throw new Error('must not apply an anchored scope mismatch') + }, + } as any) + + expect(clientCalls).toBe(0) + expect(fileProcessingState.failedEditRequiresReadByPath[pathA]).toBe(true) + // The peer target keeps valid read state: no blast-radius revocation. + expect( + fileProcessingState.failedEditRequiresReadByPath[pathB], + ).toBeFalsy() + expect(fileProcessingState.readAuthorizationsByPath?.[pathB]).toBeDefined() + + const output = result.output[0] + expect(output.type).toBe('json') + if (output.type === 'json') { + const value = output.value as { + errorMessage?: string + errorCode?: string + recovery?: { paths?: string[]; preferredStrategy?: string } + failures?: Array<{ failureKind?: string }> + } + expect(String(value.errorMessage)).toContain('lost read authorization') + expect(String(value.errorMessage)).toContain( + 'every other transaction target retains valid read state', + ) + expect(String(value.errorMessage)).not.toContain( + 'Atomic recovery requires fresh read state for every transaction target', + ) + expect(value.errorCode).toBe('no_match') + expect(value.recovery?.preferredStrategy).toBe('replace_range') + expect(value.recovery?.paths).toEqual([pathA]) + expect(value.failures?.[0]?.failureKind).toBe('anchor_scope_mismatch') + } + }) }) }) @@ -8040,3 +8280,336 @@ describe('processStream cross-turn read-before-edit', () => { ) }) }) + +describe('edit_transaction idempotent skipIfMissing deletions', () => { + it('reports a single-edit all-skip transaction as a successful no-op instead of an error', async () => { + const path = 'src/idempotent-cleanup.ts' + const diskContent = 'export const value = 1\n' + + const result = await processEditTransaction({ + edits: [ + { + type: 'str_replace', + path, + replacements: [ + { + oldString: 'console.log("already removed")\n', + newString: '', + allowMultiple: false, + skipIfMissing: true, + }, + ], + }, + ], + initialContentByPath: new Map([[path, diskContent]]), + logger, + }) + + expect('error' in result).toBe(false) + if ('files' in result) { + expect(result.files).toEqual([]) + expect(result.message).toContain('already-applied skipIfMissing deletion') + expect(result.message).toContain(path) + } + }) + + it('rejects skipIfMissing with a non-empty newString in the edit_transaction schema', () => { + const parsed = editTransactionParams.inputSchema.safeParse({ + edits: [ + { + type: 'str_replace', + path: 'src/idempotent-cleanup.ts', + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = 2', + skipIfMissing: true, + }, + ], + }, + ], + }) + + expect(parsed.success).toBe(false) + if (!parsed.success) { + expect( + parsed.error.issues.some((issue) => + issue.message.includes( + 'skipIfMissing is only valid for deletion replacements with an empty newString', + ), + ), + ).toBe(true) + } + }) + + it('accepts skipIfMissing on a deletion replacement in the edit_transaction schema', () => { + const parsed = editTransactionParams.inputSchema.safeParse({ + edits: [ + { + type: 'str_replace', + path: 'src/idempotent-cleanup.ts', + replacements: [ + { + oldString: 'console.log("already removed")\n', + newString: '', + skipIfMissing: true, + }, + ], + }, + ], + }) + + expect(parsed.success).toBe(true) + }) + + it('drives handleEditTransaction through the zero-change guard without calling the client or touching read state', async () => { + const path = 'src/idempotent-cleanup.ts' + const diskContent = 'export const value = 1\n' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + fileProcessingState.readAuthorizationsByPath = { [path]: true } + fileProcessingState.readAuthorizationHashesByPath = { + [path]: getContentHash(diskContent), + } + let clientCalls = 0 + + const transactionResult = await handleEditTransaction({ + ...defaultTestHandlerAuthority, + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'transaction-all-skip', + toolName: 'edit_transaction', + input: { + edits: [ + { + type: 'str_replace', + path, + replacements: [ + { + oldString: 'console.log("already removed")\n', + newString: '', + allowMultiple: false, + skipIfMissing: true, + }, + ], + }, + ], + }, + }, + fileProcessingState, + logger, + requestOptionalFile: async ({ filePath }: { filePath: string }) => + filePath === path ? diskContent : null, + requestClientToolCall: async () => { + clientCalls += 1 + throw new Error('an all-skip transaction must not reach the client') + }, + } as any) + + expect(clientCalls).toBe(0) + const output = transactionResult.output[0] + expect(output.type).toBe('json') + if (output.type === 'json') { + const value = output.value as { message?: string; files?: unknown[] } + expect(value).not.toHaveProperty('errorMessage') + expect(value.message).toContain('already-applied skipIfMissing deletion') + expect(value.message).toContain(path) + expect(value.files).toEqual([]) + } + // Nothing changed on disk, so neither reread markers nor the seeded + // read authorization may be disturbed by the zero-change guard. + expect(fileProcessingState.failedEditRequiresReadByPath[path]).toBeFalsy() + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) + expect( + fileProcessingState.editRereadRequirementsByPath?.[path], + ).toBeUndefined() + }) + + it('skips already-applied deletion edits inside a transaction while another path still changes', async () => { + const skippedPath = 'src/idempotent-cleanup.ts' + const changedPath = 'src/helper.ts' + + const result = await processEditTransaction({ + edits: [ + { + type: 'str_replace', + path: skippedPath, + replacements: [ + { + oldString: 'console.log("already removed")\n', + newString: '', + allowMultiple: false, + skipIfMissing: true, + }, + ], + }, + { + type: 'str_replace', + path: changedPath, + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = 2', + allowMultiple: false, + }, + ], + }, + ], + initialContentByPath: new Map([ + [skippedPath, 'export const value = 1\n'], + [changedPath, 'export const value = 1\n'], + ]), + logger, + }) + + expect('error' in result).toBe(false) + if ('files' in result) { + expect(result.files.map((file) => file.path)).toEqual([changedPath]) + expect(result.files[0]?.patch).toContain('+export const value = 2') + // The skipped path produces no files[] entry, so its skip message must + // still be appended to the mixed-transaction success message. + expect(result.message).toContain( + 'Skipped already-applied str_replace deletion', + ) + expect(result.message).toContain(skippedPath) + } + }) + + it('does not claim every edit was a skipIfMissing deletion when a co-present content edit merely produced no diff', async () => { + const skippedPath = 'src/idempotent-cleanup.ts' + const identicalPath = 'src/unchanged.ts' + const identicalContent = 'export const value = 1\n' + + const result = await processEditTransaction({ + edits: [ + { + type: 'str_replace', + path: skippedPath, + replacements: [ + { + oldString: 'console.log("already removed")\n', + newString: '', + allowMultiple: false, + skipIfMissing: true, + }, + ], + }, + { + type: 'write_file', + path: identicalPath, + content: identicalContent, + }, + ], + initialContentByPath: new Map([ + [skippedPath, 'export const value = 1\n'], + [identicalPath, identicalContent], + ]), + logger, + }) + + expect('error' in result).toBe(false) + if ('files' in result) { + expect(result.files).toEqual([]) + // A byte-identical write_file is not an already-applied skipIfMissing + // deletion, so the zero-change success message must not claim that every + // requested edit resolved to one. + expect(result.message).not.toContain( + 'every requested edit was an already-applied skipIfMissing deletion', + ) + expect(result.message).toContain('no file changes; skipped paths:') + expect(result.message).toContain(skippedPath) + expect(result.message).toContain( + 'Skipped already-applied str_replace deletion', + ) + } + }) + + it('rejects skipIfMissing with a non-empty newString identically on the str_replace surface', () => { + const parsed = strReplaceParams.inputSchema.safeParse({ + path: 'src/idempotent-cleanup.ts', + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = 2', + skipIfMissing: true, + }, + ], + }) + + expect(parsed.success).toBe(false) + if (!parsed.success) { + expect( + parsed.error.issues.some((issue) => + issue.message.includes( + 'skipIfMissing is only valid for deletion replacements with an empty newString', + ), + ), + ).toBe(true) + } + }) + + it('rejects skipIfMissing with a non-empty newString on both declared provider surfaces', () => { + // The provider-declared shapes must never advertise a combination the input + // schemas reject, so they carry the same refinement. + const strReplaceParsed = strReplaceParams.providerInputSchema?.safeParse({ + path: 'src/idempotent-cleanup.ts', + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = 2', + skipIfMissing: true, + }, + ], + }) + expect(strReplaceParsed?.success).toBe(false) + + const transactionParsed = + editTransactionParams.providerInputSchema?.safeParse({ + edits: [ + { + type: 'str_replace', + path: 'src/idempotent-cleanup.ts', + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = 2', + skipIfMissing: true, + }, + ], + }, + ], + }) + expect(transactionParsed?.success).toBe(false) + + // A real deletion still parses on both provider surfaces. + expect( + strReplaceParams.providerInputSchema?.safeParse({ + path: 'src/idempotent-cleanup.ts', + replacements: [ + { + oldString: 'console.log("already removed")\n', + newString: '', + skipIfMissing: true, + }, + ], + })?.success, + ).toBe(true) + expect( + editTransactionParams.providerInputSchema?.safeParse({ + edits: [ + { + type: 'str_replace', + path: 'src/idempotent-cleanup.ts', + replacements: [ + { + oldString: 'console.log("already removed")\n', + newString: '', + skipIfMissing: true, + }, + ], + }, + ], + })?.success, + ).toBe(true) + }) +}) diff --git a/packages/agent-runtime/src/process-edit-transaction.ts b/packages/agent-runtime/src/process-edit-transaction.ts index 39ee1efc52..55d38184bd 100644 --- a/packages/agent-runtime/src/process-edit-transaction.ts +++ b/packages/agent-runtime/src/process-edit-transaction.ts @@ -93,6 +93,7 @@ type TransactionFailureKind = | 'capability_scope' | 'capability_invalid' | 'no_match' + | 'anchor_scope_mismatch' | 'preflight_failed' | 'generic' @@ -126,8 +127,10 @@ type TransactionFailure = { basedOnRead?: string /** * Structured failure classification for capability, match, or preflight failures. - * Lets consumers classify without regex-matching errorMessage. Optional and - * additive; older consumers ignore it (the output schema strips unknown keys). + * Every failure this module reports carries one, so consumers must classify on + * this field instead of regex-matching errorMessage (which would duplicate the + * classification and let the copies drift). Still optional on the type so older + * consumers can ignore it (the output schema strips unknown keys). */ failureKind?: TransactionFailureKind } @@ -169,8 +172,23 @@ export async function processEditTransaction(params: { } = params const workingContentByPath = new Map(initialContentByPath) const messagesByPath = new Map() + // Paths whose requested change resolved to an explicit already-applied + // skipIfMissing deletion. Such an edit produces no diff, so the zero-change + // branch below must report success (documented idempotent cleanup retry) + // instead of 'edit_transaction produced no file changes.' + const noOpSkipPaths = new Set() + // Every edit processed in this loop is a content edit (delete/move are + // handled by the client-change builder in the handler). Counting them next to + // the no-op skips is what lets the zero-change branch below distinguish "every + // requested edit was an already-applied skipIfMissing deletion" from "a + // co-present content edit legitimately produced no diff". + let contentEditCount = 0 + let noOpSkipEditCount = 0 const failures: TransactionFailure[] = [] - const transformationLedgerByPath = new Map() + const transformationLedgerByPath = new Map< + string, + TransformationLedgerEntry[] + >() const unmappableOriginalPaths = new Set() for (let editIndex = 0; editIndex < edits.length; editIndex++) { const edit = edits[editIndex] @@ -180,11 +198,14 @@ export async function processEditTransaction(params: { const nextEditIndex = coalescedEdit?.nextEditIndex ?? editIndex + 1 if (!workingContentByPath.has(effectiveEdit.path)) { + const errorMessage = `Cannot apply ${effectiveEdit.type} edit to ${effectiveEdit.path}: file was not preloaded for transaction preflight. Re-read the target file, then retry the whole transaction.` + const failureKind = classifyTransactionFailureKind(errorMessage) failures.push({ editIndex, ...(effectiveEdit.id && { id: effectiveEdit.id }), path: effectiveEdit.path, - errorMessage: `Cannot apply ${effectiveEdit.type} edit to ${effectiveEdit.path}: file was not preloaded for transaction preflight. Re-read the target file, then retry the whole transaction.`, + errorMessage, + ...(failureKind && { failureKind }), }) break } @@ -194,12 +215,15 @@ export async function processEditTransaction(params: { initialContentByPath.get(effectiveEdit.path) ?? null, ) if ('error' in resolvedEdit) { + const failureKind = + resolvedEdit.failureKind ?? + classifyTransactionFailureKind(resolvedEdit.error) failures.push({ editIndex, ...(effectiveEdit.id && { id: effectiveEdit.id }), path: effectiveEdit.path, errorMessage: resolvedEdit.error, - ...(resolvedEdit.failureKind && { failureKind: resolvedEdit.failureKind }), + ...(failureKind && { failureKind }), }) break } @@ -210,11 +234,13 @@ export async function processEditTransaction(params: { unmappableOriginalPaths.has(effectiveEdit.path), ) if ('error' in rangeAdjustment) { + const failureKind = classifyTransactionFailureKind(rangeAdjustment.error) failures.push({ editIndex, ...(effectiveEdit.id && { id: effectiveEdit.id }), path: effectiveEdit.path, errorMessage: rangeAdjustment.error, + ...(failureKind && { failureKind }), }) break } @@ -252,6 +278,11 @@ export async function processEditTransaction(params: { break } + contentEditCount++ + if (result.hadNoOpSkip) { + noOpSkipEditCount++ + noOpSkipPaths.add(effectiveEdit.path) + } const priorContent = currentContent ?? '' workingContentByPath.set(effectiveEdit.path, result.content) const ledgerResult = appendTransformationLedgerEntries( @@ -323,6 +354,31 @@ export async function processEditTransaction(params: { } if (files.length === 0) { + // Documented contract: a transaction whose every requested change was an + // explicit already-applied skipIfMissing deletion is a SUCCESSFUL + // idempotent cleanup retry, not a failure. It reports zero file changes + // plus the per-path skip messages so the caller can see why nothing + // changed; the handler surfaces this without asking the client to apply an + // empty change list. + if (noOpSkipPaths.size > 0) { + const skippedPaths = [...noOpSkipPaths] + // Only assert the strong idempotent-cleanup claim when it is literally + // true for EVERY content edit. A co-present content edit that legitimately + // produced no diff (e.g. write_file with byte-identical content) also + // lands here, and it is not an already-applied skipIfMissing deletion, so + // that case reports the neutral wording instead. + const everyEditWasNoOpSkip = noOpSkipEditCount === contentEditCount + return { + tool: 'edit_transaction', + files: [], + message: [ + everyEditWasNoOpSkip + ? `edit_transaction made no file changes: every requested edit was an already-applied skipIfMissing deletion (${skippedPaths.join(', ')}).` + : `edit_transaction made no file changes; skipped paths: ${skippedPaths.join(', ')}.`, + ...skippedPaths.flatMap((path) => messagesByPath.get(path) ?? []), + ].join('\n'), + } + } return { tool: 'edit_transaction', error: @@ -331,10 +387,22 @@ export async function processEditTransaction(params: { } } + // Mixed transaction: a path whose every replacement resolved to an + // already-applied skipIfMissing deletion produces no files[] entry, so its + // skip messages would be lost whenever another path did change. Surface them + // on the success message instead of only through files[]. + const skippedOnlyPaths = [...noOpSkipPaths].filter( + (skippedPath) => !files.some((file) => file.path === skippedPath), + ) return { tool: 'edit_transaction', files, - message: `edit_transaction preflight prepared ${files.length} coordinated file change(s).`, + message: [ + `edit_transaction preflight prepared ${files.length} coordinated file change(s).`, + ...skippedOnlyPaths.flatMap( + (skippedPath) => messagesByPath.get(skippedPath) ?? [], + ), + ].join('\n'), } } @@ -416,6 +484,12 @@ function collectTransactionPaths( return [...paths] } +/** + * Prose fallback for failures that do not already carry a structured kind. An + * anchored scope mismatch is deliberately NOT detected here: processStrReplace + * reports it as a real failureKind, so no marker token has to travel inside + * model-facing prose where a quoted oldString could forge it. + */ function classifyTransactionFailureKind( errorMessage: string, ): TransactionFailureKind | undefined { @@ -464,6 +538,9 @@ function recoveryErrorCode( return 'stale_capability' } if (failure.failureKind === 'no_match') return 'no_match' + // The public errorCode enum intentionally does not grow: an anchored scope + // mismatch is reported as no_match with the precise prose in the failure. + if (failure.failureKind === 'anchor_scope_mismatch') return 'no_match' if (failure.failureKind === 'preflight_failed') return 'preflight_failed' if (classifyTransactionFailureKind(failure.errorMessage) === 'no_match') { return 'no_match' @@ -481,8 +558,14 @@ function buildTransactionRecovery(params: { failure.failureKind ?? classifyTransactionFailureKind(failure.errorMessage) const isCapability = typeof kind === 'string' && kind.startsWith('capability') - const isMatchFailure = kind === 'no_match' + // An anchored scope mismatch needs the same fresh-read handling as a match + // failure, but the correct fix is a capability that actually covers the target + // lines, so replace_range is the preferred strategy rather than a shorter + // oldString. + const isAnchorScopeMismatch = kind === 'anchor_scope_mismatch' + const isMatchFailure = kind === 'no_match' || isAnchorScopeMismatch const prefersReplaceRange = + isAnchorScopeMismatch || /replace_range with its readCapability|Do not reconstruct huge blocks from memory|No useful candidate ranges found/i.test( failure.errorMessage, ) @@ -523,7 +606,11 @@ function originalLineSpan( const normalized = normalizeLineEndings(content) const lines = normalized.split('\n') const visibleLineCount = - normalized.length === 0 ? 0 : lines.at(-1) === '' ? lines.length - 1 : lines.length + normalized.length === 0 + ? 0 + : lines.at(-1) === '' + ? lines.length - 1 + : lines.length if (startLine < 1 || endLine < startLine || endLine > visibleLineCount) { return null } @@ -582,7 +669,7 @@ function resolveReplaceRangeEdit( ? decoded : 'readCapability requires an authenticated project/path/run-bound cap.v3 token.', // Only a decode failure carries the structured capability-invalid kind; - // a wrong-version token stays covered by the handler regex fallback. + // a wrong-version token is classified by classifyTransactionFailureKind. ...(typeof decoded === 'string' ? { failureKind: 'capability_invalid' as const } : {}), @@ -802,6 +889,14 @@ async function processTransactionEdit(params: { | { content: string messages: string[] + /** + * True when EVERY replacement of this edit resolved to an already-applied + * skipIfMissing deletion, i.e. the whole edit is a deliberate no-op rather + * than a content change. A mixed batch (one already-applied skip plus a + * replacement that really applies) is a content change and never sets + * this, so its applied content is never discarded. + */ + hadNoOpSkip?: boolean } | { error: string diff --git a/packages/agent-runtime/src/process-str-replace.ts b/packages/agent-runtime/src/process-str-replace.ts index d117a2666d..783da8bad3 100644 --- a/packages/agent-runtime/src/process-str-replace.ts +++ b/packages/agent-runtime/src/process-str-replace.ts @@ -43,15 +43,16 @@ function normalizeBasedOnRead( // tool-call hygiene (e.g. an editor emitting basedOnRead: "dummy") look fine on // small files, then fail confusingly on the first large file. We reject them up // front everywhere so the mistake surfaces immediately and consistently. +// Generic literals like 'null'/'undefined'/'none' are intentionally excluded +// to avoid false positives on legitimate narrow oldString anchors; only +// explicit placeholder tokens (and their cap.-prefixed variants) are +// considered bogus. Malformed cap tokens are still caught via decode failure. const BOGUS_READ_CAPABILITY_VALUES = new Set([ 'dummy', 'todo', 'tbd', 'fixme', 'placeholder', - 'none', - 'null', - 'undefined', 'cap.dummy', 'cap.todo', 'cap.placeholder', @@ -88,20 +89,127 @@ const FAILED_EDIT_RECOVERY_GUIDANCE = [ 'Base the next edit on the fresh read, not on the failed oldString.', ].join('\n') -function addFailedEditRecoveryGuidance(error: string): string { - // Scope mismatches are authenticity failures, not evidence that file content - // changed or disappeared. Keep their recovery precise while preserving the - // cap.v3 project/path/run anti-replay boundary. - if ( - error.includes( - 'read capability belongs to a different project, path, or agent run', - ) - ) { +/** + * Structured classification for the str_replace failures whose recovery differs + * from the generic "re-read and copy the current text" path. This is plumbed as + * a real field (and re-exported through the transaction failure record) so no + * consumer has to sniff model-facing prose for a sentinel token — a failing + * oldString copied out of these very files must never be misclassified. + */ +export type StrReplaceFailureKind = 'anchor_scope_mismatch' | 'capability_scope' + +// Kinds whose targeted recovery is already complete. Appending the generic +// guidance to an anchored scope mismatch is wrong: it already proves the +// oldString still EXISTS in the current file, just outside the supplied +// basedOnRead window — so "re-read and copy the current text into oldString" +// would return the identical string and loop forever. +const RECOVERY_GUIDANCE_SUPPRESSING_KINDS = new Set([ + 'anchor_scope_mismatch', +]) + +function addFailedEditRecoveryGuidance( + error: string, + failureKind?: StrReplaceFailureKind, +): string { + if (failureKind && RECOVERY_GUIDANCE_SUPPRESSING_KINDS.has(failureKind)) { return error } return `${error}\n\n${FAILED_EDIT_RECOVERY_GUIDANCE}` } +type RecordedFailure = { error: string; kind?: StrReplaceFailureKind } + +/** + * Classification (and therefore guidance suppression) is decided per failure, + * never from the joined batch text: a mixed atomic batch — one anchored scope + * mismatch plus a genuine no-match — must keep FAILED_EDIT_RECOVERY_GUIDANCE + * for the co-failing replacement and must NOT be reported as a scope mismatch, + * because that would also narrow invalidation for an unrelated failure. + */ +function aggregateFailureKind( + failures: RecordedFailure[], +): StrReplaceFailureKind | undefined { + const firstKind = failures[0]?.kind + if (!firstKind) return undefined + return failures.every((failure) => failure.kind === firstKind) + ? firstKind + : undefined +} + +/** + * Single source of truth for the idempotent-deletion skip. A `skipIfMissing` + * deletion whose oldString is absent from `searchContent` is an already-applied + * no-op and must never fail the batch. Returns the model-facing skip message + * when the skip applies, otherwise null. `anchored` only affects wording: it + * names the anchored window so a scoped skip is never mistaken for a whole-file + * absence claim. Both call sites (the occurrenceIndex path and the general + * path) go through this helper so the two copies cannot drift. + * + * When `occurrenceIndex` is supplied, a PARTIALLY-applied cleanup also skips: + * fewer remaining exact occurrences than the requested 1-indexed occurrence + * means that occurrence can no longer be targeted, so the deletion is treated + * as already applied instead of hard-failing the whole atomic batch. + * + * Both unanchored pre-gate call sites deliberately run BEFORE the stale-anchor + * and strict read-before-edit gates: an anchored window is always a SUBSET of + * the file, so whole-file absence (or a whole-file remaining count below + * occurrenceIndex) proves the same for any window without needing anchor + * freshness, and nothing is mutated on either outcome. Sound only in the SKIP + * direction — a whole-file count never authorizes APPLYING an edit under a + * stale anchor. Those callers pass `discloseRemainingCount: false` (strict mode, + * or a supplied stale anchor) so a caller that would otherwise be strict-blocked + * learns only that the occurrence is already applied, never the exact remaining + * count; the anchored/fresh path keeps the exact count. + */ +function tryIdempotentDeletionSkip(params: { + searchContent: string + oldStr: string + newStr: string + skipIfMissing: boolean | undefined + path: string + anchored: boolean + occurrenceIndex?: number + discloseRemainingCount?: boolean +}): string | null { + const { + searchContent, + oldStr, + newStr, + skipIfMissing, + path, + anchored, + occurrenceIndex, + discloseRemainingCount = true, + } = params + if (skipIfMissing !== true || newStr !== '') return null + const scopeSuffix = anchored ? ' within the anchored range' : '' + if (occurrenceIndex !== undefined) { + // ONE bounded walk answers both questions on a module that targets 100KB+ + // files: the shared occurrence walk stops after occurrenceIndex matches, so + // its length simultaneously proves absence (fewer than occurrenceIndex + // remain) and supplies the exact remaining count for the message. Nothing + // scans past occurrenceIndex and no substring array is materialized. + const remaining = findLiteralOccurrences( + searchContent, + oldStr, + occurrenceIndex, + ).length + if (remaining >= occurrenceIndex) return null + // A remaining count below occurrenceIndex only proves that fewer than N + // exact occurrences exist NOW; it cannot distinguish an occurrence that was + // already deleted from one that was never present N times. Word it as + // "treated as already applied" so the model is never told a false history. + // Callers with no fresh capability get the boolean form only: the exact + // count is reserved for the anchored/fresh path. + if (!discloseRemainingCount) { + return `Skipped already-applied str_replace deletion in ${path}: fewer than ${occurrenceIndex} exact occurrence(s) of the oldString remain${scopeSuffix}, so occurrenceIndex ${occurrenceIndex} is treated as already applied.` + } + return `Skipped already-applied str_replace deletion in ${path}: only ${remaining} exact occurrence(s) of the oldString remain${scopeSuffix}, i.e. fewer than ${occurrenceIndex}, so occurrenceIndex ${occurrenceIndex} is treated as already applied.` + } + if (searchContent.includes(oldStr)) return null + return `Skipped already-applied str_replace deletion in ${path}: oldString was not present${scopeSuffix}.` +} + export async function processStrReplace(params: { path: string replacements: { @@ -133,8 +241,29 @@ export async function processStrReplace(params: { patch: string messages: string[] failedReplacementCount: number + /** + * True ONLY when EVERY replacement resolved to an already-applied + * skipIfMissing deletion, so `patch` is empty and no content changed. + * Consumers (edit_transaction, the str_replace handler's zero-change + * guard) short-circuit on this flag to report a successful idempotent + * cleanup retry instead of "produced no file changes", so a mixed batch — + * one already-applied skip plus a replacement that really applies — + * deliberately never sets it and its applied content is never discarded. + */ + hadNoOpSkip?: boolean + /** Structured flag indicating a near-match autocorrect was applied. */ + hadAutoCorrect?: boolean + } + | { + tool: 'str_replace' + path: string + error: string + /** + * Structured failure classification. Consumers (process-edit-transaction, + * the edit_transaction handler) key off this instead of matching prose. + */ + failureKind?: StrReplaceFailureKind } - | { tool: 'str_replace'; path: string; error: string } > { const { path, @@ -163,7 +292,7 @@ export async function processStrReplace(params: { // match, NONE are applied. Large files are always atomic to prevent confusing // partial-apply state that shifts line numbers and invalidates read anchors; // small files can opt in with atomic: true for logically grouped edits. - const failures: string[] = [] + const failures: RecordedFailure[] = [] const defaultLineEnding = getDominantLineEnding(currentContent) const initialContentLineCount = normalizeLineEndings(initialContent).split('\n').length @@ -171,17 +300,21 @@ export async function processStrReplace(params: { initialContent.length > LARGE_FILE_CHAR_THRESHOLD || initialContentLineCount > LARGE_FILE_LINE_THRESHOLD const useAtomicBatch = isLargeFile || atomic - // Large files require deterministic targeting. On every file size, an - // explicitly supplied basedOnRead is also an explicit scope request and must - // remain fresh; callers that want an unscoped unique-literal edit should omit - // the capability. + // Large files require deterministic targeting. A supplied basedOnRead is also + // an explicit scope request, so on large files (and whenever strict + // read-before-edit is required) it must remain fresh. Small files have one + // deliberate exception: the uniqueStaleStrip loop-breaker below ignores a + // stale anchor when oldString is uniquely matchable, applying the edit as a + // naked unique-literal edit with a warning message instead of hard-failing. + // Callers that never want scoping should simply omit the capability. const enforceReadCapability = isLargeFile || requireFreshReadCapability const normalizedInitialContent = normalizeLineEndings(initialContent) const validatedReadRanges = new Map() const readCapabilityWarnings: string[] = [] const preflightErrors: string[] = [] const capabilityAuthorityErrors: string[] = [] - let hadNoOpSkip = false + let noOpSkipCount = 0 + let hadAutoCorrect = false // Decode any token-form basedOnRead up front so the rest of the pipeline only // ever sees concrete { startLine, endLine, hash } objects (or undefined). @@ -231,6 +364,7 @@ export async function processStrReplace(params: { tool: 'str_replace' as const, path, error: capabilityAuthorityErrors.join('\n\n'), + failureKind: 'capability_scope' as const, } } @@ -263,6 +397,12 @@ export async function processStrReplace(params: { if (uniquelyMatchable && !requireFreshReadCapability) { normalizedReplacements[i].basedOnRead = undefined autoStrippedBogusAnchor = true + messages.push( + [ + `Note: an invalid basedOnRead anchor was ignored for ${path} because the oldString was uniquely matchable, so the edit applied as a naked edit.`, + 'Stop passing placeholder/invalid basedOnRead values. Omit basedOnRead when oldString is unique, or copy the readCapability token from a fresh read_files header.', + ].join('\n'), + ) continue } @@ -291,11 +431,9 @@ export async function processStrReplace(params: { // Validate it so matching never silently expands to the whole file. if (enforceReadCapability || hasSuppliedReadCapability) { for (const { basedOnRead } of normalizedReplacements) { - if (!basedOnRead) continue - if (typeof basedOnRead === 'string') { - preflightErrors.push(basedOnRead) - continue - } + // String-form anchors never reach here: the bogus-anchor loop above either + // returned for an undecodable token or rewrote it to undefined. + if (!basedOnRead || typeof basedOnRead === 'string') continue const key = getReadCapabilityKey(basedOnRead) if (validatedReadRanges.has(key)) continue const validatedRange = validateReadCapability({ @@ -316,14 +454,6 @@ export async function processStrReplace(params: { } } - if (preflightErrors.length > 0) { - return { - tool: 'str_replace' as const, - path, - error: addFailedEditRecoveryGuidance(preflightErrors.join('\n\n')), - } - } - for (const [ replacementIndex, replacement, @@ -336,10 +466,11 @@ export async function processStrReplace(params: { basedOnRead, skipIfMissing, } = replacement - const recordFailure = (error: string) => { - failures.push( - `Replacement ${replacementIndex + 1}/${normalizedReplacements.length} failed:\n${error}`, - ) + const recordFailure = (error: string, kind?: StrReplaceFailureKind) => { + failures.push({ + error: `Replacement ${replacementIndex + 1}/${normalizedReplacements.length} failed:\n${error}`, + ...(kind && { kind }), + }) } const normalizedCurrentContent = normalizeLineEndings(currentContent) const normalizedOldStr = normalizeLineEndings(oldStr) @@ -373,6 +504,35 @@ export async function processStrReplace(params: { validatedRange: freshValidatedRangeForIndex, }) : null + // Tracks that the unanchored whole-file check below already ran, so the + // anchored call further down is not repeated with identical arguments + // (searchContent === normalizedCurrentContent) on a 100KB+ file. + let wholeFileSkipChecked = false + if (!validatedRangeForIndex) { + // Unanchored pre-gate: a whole-file remaining count below + // occurrenceIndex proves the anchored (subset) count is below it too, so + // this runs before the stale-anchor and strict read-before-edit gates + // and nothing is mutated. See tryIdempotentDeletionSkip for the full + // subset/disclosure argument; the exact remaining count is withheld here + // because this caller has no fresh capability. + wholeFileSkipChecked = true + const wholeFileOccurrenceSkip = tryIdempotentDeletionSkip({ + searchContent: normalizedCurrentContent, + oldStr: normalizedOldStr, + newStr: normalizedNewStr, + skipIfMissing, + path, + anchored: false, + occurrenceIndex, + discloseRemainingCount: + !requireFreshReadCapability && basedOnRead === undefined, + }) + if (wholeFileOccurrenceSkip) { + messages.push(wholeFileOccurrenceSkip) + noOpSkipCount++ + continue + } + } if (requireFreshReadCapability && !validatedRangeForIndex) { const occurrenceFailure = [ `Strict read-before-edit blocked replacement ${replacementIndex + 1}/${normalizedReplacements.length} for ${path}: basedOnRead did not match the current file content.`, @@ -395,25 +555,41 @@ export async function processStrReplace(params: { } const searchContent = validatedRangeForIndex?.content ?? normalizedCurrentContent - if ( - skipIfMissing === true && - normalizedNewStr === '' && - !searchContent.includes(normalizedOldStr) - ) { - messages.push( - `Skipped already-applied str_replace deletion in ${path}: oldString was not present${validatedRangeForIndex ? ' within the anchored range' : ''}.`, - ) - hadNoOpSkip = true + // Only the anchored variant can still find work here: when no fresh + // validated range narrowed searchContent, the pre-gate above already ran + // this exact check against the whole file, so repeating it would be a + // guaranteed-null re-walk of every byte. + const occurrenceDeletionSkip = wholeFileSkipChecked + ? null + : tryIdempotentDeletionSkip({ + searchContent, + oldStr: normalizedOldStr, + newStr: normalizedNewStr, + skipIfMissing, + path, + anchored: Boolean(validatedRangeForIndex), + occurrenceIndex, + }) + if (occurrenceDeletionSkip) { + messages.push(occurrenceDeletionSkip) + noOpSkipCount++ continue } - const at = getNthOccurrenceIndex( + const at = nthLiteralOccurrenceIndex( searchContent, normalizedOldStr, occurrenceIndex, ) if (at === -1) { - const totalOccurrences = - searchContent.split(normalizedOldStr).length - 1 + // Bounded occurrence walk instead of split(): at === -1 already proves + // fewer than occurrenceIndex occurrences exist, so a walk capped at + // occurrenceIndex counts all of them without materializing a full + // substring array of a 100KB+ file. + const totalOccurrences = findLiteralOccurrences( + searchContent, + normalizedOldStr, + occurrenceIndex, + ).length const occurrenceFailure = [ `Could not apply occurrenceIndex ${occurrenceIndex} for ${path}: only ${totalOccurrences} exact occurrence(s) of the oldString exist${validatedRangeForIndex ? ' within the anchored range' : ''}.`, 'Re-read the file/range to confirm how many occurrences exist, then pass a valid 1-indexed occurrenceIndex.', @@ -491,6 +667,30 @@ export async function processStrReplace(params: { Boolean(basedOnRead && typeof basedOnRead === 'object') && !hasFreshBasedOnRead + // Unanchored pre-gate FIRST, before both stale-anchor gates and the strict + // `requireFreshReadCapability` gate: a capability window is a SUBSET of the + // file, so whole-file absence proves window absence without anchor freshness + // and nothing is mutated. This is what lets an idempotent cleanup retry + // replaying its now-stale anchor skip instead of failing 'Scoped str_replace + // blocked' / 'Large-file edit blocked'. See tryIdempotentDeletionSkip for + // the full subset/disclosure argument. The cap.v3 authenticity/scope + // preflight still runs strictly earlier; only CONTENT staleness is ordered + // after this skip. The anchored variant stays below, after the validated + // range is resolved, so a window-scoped skip still reports its range. + const wholeFileDeletionSkip = tryIdempotentDeletionSkip({ + searchContent: normalizedCurrentContent, + oldStr: normalizedOldStr, + newStr: normalizedNewStr, + skipIfMissing, + path, + anchored: false, + }) + if (wholeFileDeletionSkip) { + messages.push(wholeFileDeletionSkip) + noOpSkipCount++ + continue + } + if (hasStaleBasedOnRead && !requireFreshReadCapability) { // Loop-breaker for small files only (mirrors autoStrippedBogusAnchor): // when basedOnRead is stale but oldString uniquely identifies a spot, @@ -571,15 +771,62 @@ export async function processStrReplace(params: { : null const matchContent = validatedReadRange?.content ?? normalizedCurrentContent + // Deliberate ordering: this idempotent-deletion skip runs BEFORE the + // anchored scope-mismatch gate below. skipIfMissing on a deletion is an + // explicit "delete this only if it is still here", and the anchor scopes + // where "here" is, so an oldString missing from the anchored window is a + // no-op even when it still occurs elsewhere in the file. The message names + // the anchored range so the skip is never mistaken for a whole-file claim. + // Both behaviors are locked by the [ABI-M07] tests in + // __tests__/process-str-replace.test.ts; flipping the order would abort the + // whole atomic batch with anchor_scope_mismatch instead. + // Only the anchored variant can still find work here: without a fresh + // validated range matchContent IS normalizedCurrentContent, and the + // unconditional whole-file pre-gate above already ran this identical check, + // so repeating it would be a guaranteed-null re-walk of every byte. Mirrors + // the occurrenceIndex path's `wholeFileSkipChecked` guard. + const anchoredDeletionSkip = validatedReadRange + ? tryIdempotentDeletionSkip({ + searchContent: matchContent, + oldStr: normalizedOldStr, + newStr: normalizedNewStr, + skipIfMissing, + path, + anchored: true, + }) + : null + if (anchoredDeletionSkip) { + messages.push(anchoredDeletionSkip) + noOpSkipCount++ + continue + } + + // Anchored scope mismatch: the supplied capability was FRESH and hash-valid, + // but its window does not contain the oldString while the current file does. + // Reporting a whole-file "not an exact contiguous match" here would be a lie + // (nothing changed or was removed) and the similarity/candidate numbers would + // only describe the anchored window. Report the real outside locations instead. if ( - skipIfMissing === true && - normalizedNewStr === '' && - !matchContent.includes(normalizedOldStr) + validatedReadRange && + !matchContent.includes(normalizedOldStr) && + normalizedCurrentContent.includes(normalizedOldStr) ) { - messages.push( - `Skipped already-applied str_replace deletion in ${path}: oldString was not present${validatedReadRange ? ' within the anchored range' : ''}.`, - ) - hadNoOpSkip = true + const outsideRanges = getOccurrenceLineRanges({ + initialContent: normalizedCurrentContent, + oldStr: normalizedOldStr, + limit: 3, + }) + const scopeFailure = [ + `Anchored str_replace scope mismatch for ${path}: the supplied basedOnRead covers lines ${validatedReadRange.startLine}-${validatedReadRange.endLine}, and oldString does not occur inside that window, but it DOES occur in the current file, so the text was NOT changed or removed.`, + `oldString currently occurs at line(s): ${outsideRanges + .map((range) => `${range.startLine}-${range.endLine}`) + .join(', ')}.`, + 'Recovery: re-read the range that CONTAINS those lines with read_files and pass THAT capability as basedOnRead (or use replace_range with it); or, when oldString is unique in the file, omit basedOnRead entirely. Do not re-read the same window and resend the identical oldString.', + ].join('\n') + // Classification travels as a structured failureKind, never as a token in + // this prose: any text (e.g. a copied oldString) could otherwise forge it. + messages.push(scopeFailure) + recordFailure(scopeFailure, 'anchor_scope_mismatch') continue } const match = tryMatchOldStr({ @@ -589,6 +836,12 @@ export async function processStrReplace(params: { newStr: normalizedNewStr, allowMultiple, logger, + ...(validatedReadRange && { + anchoredRange: { + startLine: validatedReadRange.startLine, + endLine: validatedReadRange.endLine, + }, + }), }) let updatedOldStr: string | null @@ -597,6 +850,9 @@ export async function processStrReplace(params: { if (match.message) { messages.push(match.message) } + if (match.hadAutoCorrect) { + hadAutoCorrect = true + } } else { const failureMessage = useAtomicBatch ? match.error @@ -657,6 +913,10 @@ export async function processStrReplace(params: { // the file is never left half-edited. Large files always use this path; // small files use it only when the caller opts in with atomic: true. if (useAtomicBatch && failures.length > 0) { + // Per-failure suppression: only a batch whose every failure suppresses the + // generic guidance may drop it. A mixed batch keeps the guidance its genuine + // no-match needs and reports no scope-mismatch kind. + const batchFailureKind = aggregateFailureKind(failures) return { tool: 'str_replace' as const, path, @@ -668,9 +928,11 @@ export async function processStrReplace(params: { : transactionContext ? 'Use the recovery snapshot/capability when supplied; otherwise re-read the failed file/range, then retry the whole transaction. Partial success is unavailable inside edit_transaction.' : 'Use the recovery snapshot/capability when supplied; otherwise re-read the failed file/range, then retry the batch or omit atomic to allow partial success.', - ...failures, + ...failures.map((failure) => failure.error), ].join('\n\n'), + batchFailureKind, ), + ...(batchFailureKind && { failureKind: batchFailureKind }), } } @@ -681,11 +943,14 @@ export async function processStrReplace(params: { defaultLineEnding, }) - // If every requested change was an explicit idempotent no-op, report success - // so edit_transaction can continue applying later independent edits. + // If EVERY requested change was an explicit idempotent no-op, report success + // so edit_transaction can continue applying later independent edits. Only + // this branch sets hadNoOpSkip: a mixed batch (a skip co-present with a + // replacement that really applied) produces a real patch and must reach the + // client instead of being short-circuited as "no file changes". if ( initialContent === currentContent && - hadNoOpSkip && + noOpSkipCount === normalizedReplacements.length && failures.length === 0 ) { return { @@ -695,6 +960,8 @@ export async function processStrReplace(params: { patch: '', messages, failedReplacementCount: 0, + hadNoOpSkip: true, + hadAutoCorrect, } } @@ -708,10 +975,12 @@ export async function processStrReplace(params: { `processStrReplace: No change to ${path}`, ) messages.push('No change to the file') + const failureKind = aggregateFailureKind(failures) return { tool: 'str_replace' as const, path, - error: addFailedEditRecoveryGuidance(messages.join('\n\n')), + error: addFailedEditRecoveryGuidance(messages.join('\n\n'), failureKind), + ...(failureKind && { failureKind }), } } @@ -723,15 +992,6 @@ export async function processStrReplace(params: { } const finalPatch = patch - if (autoStrippedBogusAnchor) { - messages.push( - [ - `Note: an invalid basedOnRead anchor was ignored for ${path} because the oldString was uniquely matchable, so the edit applied as a naked edit.`, - 'Stop passing placeholder/invalid basedOnRead values. Omit basedOnRead when oldString is unique, or copy the readCapability token from a fresh read_files header.', - ].join('\n'), - ) - } - if (failures.length > 0) { messages.unshift( `Partial str_replace applied with ${failures.length} failed replacement(s) out of ${normalizedReplacements.length}. Re-read the failed targets before retrying them.`, @@ -748,6 +1008,9 @@ export async function processStrReplace(params: { `processStrReplace: Updated file ${path}`, ) + // This batch DID change content, so hadNoOpSkip is deliberately absent here: + // the all-skip short-circuit must never swallow these applied changes. Any + // co-present skips are reported through `messages`. return { tool: 'str_replace' as const, path, @@ -755,6 +1018,7 @@ export async function processStrReplace(params: { patch: finalPatch, messages, failedReplacementCount: failures.length, + hadAutoCorrect, } } @@ -1300,6 +1564,13 @@ function formatClosestMatchDiagnostics( similarity: number }[], oldStr?: string, + /** + * Absolute-line offset applied when the candidate matches were computed over a + * window-scoped slice (an anchored basedOnRead range). findClosestMatches + * returns 1-indexed lines relative to the content it was given, so the offset + * is added exactly once here, at render time. + */ + lineOffset: number = 0, ): string { const usefulMatches = matches.filter( (match) => match.similarity >= MIN_USEFUL_DIAGNOSTIC_SIMILARITY, @@ -1327,15 +1598,17 @@ function formatClosestMatchDiagnostics( } const candidateBlock = usefulMatches - .map((match, index) => - [ - `Candidate ${index + 1}: lines ${match.startLine}-${match.endLine} (similarity ${Math.round(match.similarity * 100)}%)`, - `Recovery read: read_files ranges: [{ path: ${JSON.stringify(path)}, startLine: ${match.startLine}, endLine: ${match.endLine} }]`, + .map((match, index) => { + const startLine = match.startLine + lineOffset + const endLine = match.endLine + lineOffset + return [ + `Candidate ${index + 1}: lines ${startLine}-${endLine} (similarity ${Math.round(match.similarity * 100)}%)`, + `Recovery read: read_files ranges: [{ path: ${JSON.stringify(path)}, startLine: ${startLine}, endLine: ${endLine} }]`, '```', match.closestBlock, '```', - ].join('\n'), - ) + ].join('\n') + }) .join('\n\n') return strategyNudge ? `${candidateBlock}\n\n${strategyNudge}` : candidateBlock @@ -1367,17 +1640,6 @@ function formatOccurrenceDiagnostics( ) } -// Returns the character index of the Nth (1-indexed) exact occurrence of oldStr -// in content, or -1 if fewer than N occurrences exist. Used by occurrenceIndex -// to target one specific repeated block without a fresh read anchor. -function getNthOccurrenceIndex( - content: string, - oldStr: string, - n: number, -): number { - return nthLiteralOccurrenceIndex(content, oldStr, n) -} - function getDeterministicLargeFileFallbackRange(params: { content: string oldStr: string @@ -1881,10 +2143,24 @@ const tryMatchOldStr = (params: { newStr: string allowMultiple: boolean logger: Logger + /** + * Absolute bounds of the anchored basedOnRead window when initialContent is a + * window-scoped slice. Present only for anchored edits, so unanchored + * diagnostics stay byte-identical. + */ + anchoredRange?: { startLine: number; endLine: number } }): - | { success: true; oldStr: string; message?: string } + | { success: true; oldStr: string; message?: string; hadAutoCorrect?: boolean } | { success: false; error: string } => { - const { path, initialContent, oldStr, newStr, allowMultiple, logger } = params + const { + path, + initialContent, + oldStr, + newStr, + allowMultiple, + logger, + anchoredRange, + } = params // count the number of occurrences of oldStr in initialContent const count = initialContent.split(oldStr).length - 1 if (count > 1 && oldStr.trim().length < TINY_ANCHOR_MULTI_MATCH_MIN_LENGTH) { @@ -1996,6 +2272,7 @@ const tryMatchOldStr = (params: { return { success: true, oldStr: nearMatch.oldStr, + hadAutoCorrect: true, message: [ `⚠ WARNING: auto-corrected a near-match edit (${Math.round(nearMatch.similarity * 100)}% similar) at lines ${nearMatch.startLine}-${nearMatch.endLine}.`, ...(nearMatch.corroboratedBySymbolIdentity @@ -2011,11 +2288,24 @@ const tryMatchOldStr = (params: { } const closestMatches = findClosestMatches({ initialContent, oldStr }) + // Candidate lines are relative to initialContent, which is the anchored window + // slice for scoped edits. Shift them once at render time so reported lines are + // absolute file lines instead of silently window-relative. + const lineOffset = anchoredRange ? anchoredRange.startLine - 1 : 0 let errorMsg = [ - `The old string ${JSON.stringify(oldStr)} is not an exact contiguous match of the current file, so it was not applied.`, + `The old string ${JSON.stringify(oldStr)} is not an exact contiguous match of ${ + anchoredRange + ? `the anchored range lines ${anchoredRange.startLine}-${anchoredRange.endLine} of the current file` + : 'the current file' + }, so it was not applied.`, 'It may be incomplete, may omit punctuation from the middle of a line, or may refer to content that changed or was removed.', ].join(' ') - const diagnostics = formatClosestMatchDiagnostics(path, closestMatches, oldStr) + const diagnostics = formatClosestMatchDiagnostics( + path, + closestMatches, + oldStr, + lineOffset, + ) if (diagnostics) { errorMsg += `\n\nClosest candidate ranges for read_files.ranges recovery:\n${diagnostics}` } else if (isLargeOldString(oldStr)) { diff --git a/packages/agent-runtime/src/tools/handlers/tool/__tests__/str-replace-circuit-breaker.test.ts b/packages/agent-runtime/src/tools/handlers/tool/__tests__/str-replace-circuit-breaker.test.ts index b3c5ea9845..d6332f3842 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/__tests__/str-replace-circuit-breaker.test.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/__tests__/str-replace-circuit-breaker.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from 'bun:test' -import { getExactContentHash } from '@codebuff/common/util/content-hash' +import { + encodeReadCapabilityToken, + getContentHash, + getExactContentHash, +} from '@codebuff/common/util/content-hash' import { mockFileContext } from '../../../../__tests__/test-utils' import { handleStrReplace } from '../str-replace' import { getFileProcessingValues } from '../write-file' -import { - encodeReadCapabilityToken, - getContentHash, -} from '../../../../process-str-replace' import type { CodebuffToolCall } from '@codebuff/common/tools/list' import type { CodebuffToolOutput } from '@codebuff/common/tools/list' @@ -122,7 +122,12 @@ const confirmedRequestClientToolCall = ] as CodebuffToolOutput<'str_replace'> } -const unreachableRequestClientToolCall = confirmedRequestClientToolCall({}) +// Deliberately throws instead of returning a successful receipt: these cases +// assert the client is never reached, so an accidental call must fail the test +// rather than silently "apply" empty content. +const unreachableRequestClientToolCall = async (): Promise => { + throw new Error('requestClientToolCall must not be reached') +} describe('handleStrReplace circuit breaker (Fix C)', () => { it('does not mint reusable authority when strict internal auto-reread fails', async () => { @@ -500,4 +505,271 @@ describe('handleStrReplace circuit breaker (Fix C)', () => { 1, ) }) + + it('charges failure budget for autocorrected near-match and surfaces symmetric limit warning', async () => { + const path = 'autocorrect-budget.ts' + const initialContent = [ + 'export function calculateTotal(items: Item[]) {', + ' const subtotal = items.reduce((sum, item) => sum + item.price, 0)', + ' return subtotal', + '}', + ].join('\n') + const driftedOldString = [ + 'export function calculateTotal(items: Item[]) {', + ' const subTotal = items.reduce((sum, item) => sum + item.price, 0)', + ' return subtotal', + '}', + ].join('\n') + const newString = [ + 'export function calculateTotal(items: Item[]) {', + ' const subtotal = items.reduce((sum, item) => sum + item.price, 0)', + ' return subtotal * 1.0825', + '}', + ].join('\n') + const fileProcessingState = getFileProcessingValues({ + consecutiveStrReplaceFailuresByPath: { [path]: 4 }, + strictReadBeforeEdit: false, + }) + const result = await handleStrReplace({ + previousToolCallFinished: Promise.resolve(), + ...handlerAuthority, + toolCall: makeStrReplaceCall({ + path, + atomic: false, + replacements: [ + { oldString: driftedOldString, newString, allowMultiple: false }, + ], + }), + fileProcessingState, + logger: silentLogger, + requestClientToolCall: confirmedRequestClientToolCall({ + [path]: newString, + }), + requestOptionalFile: async () => initialContent, + writeToClient: noopWriteToClient, + }) + const value = result.output[0]?.value as + | { message?: string; errorMessage?: string } + | undefined + expect(value?.errorMessage).toBeUndefined() + expect(value?.message).toContain('auto-corrected a near-match edit') + expect(value?.message).toContain('str_replace retry limit reached') + expect(fileProcessingState.consecutiveStrReplaceFailuresByPath[path]).toBe( + 5, + ) + }) + + it('does not increment failure budget on preflight syntax error (bypass)', async () => { + const path = 'syntax-bypass.ts' + const fileContent = 'export const value = 1\n' + const fileProcessingState = getFileProcessingValues({ + consecutiveStrReplaceFailuresByPath: { [path]: 2 }, + strictReadBeforeEdit: false, + }) + const result = await handleStrReplace({ + previousToolCallFinished: Promise.resolve(), + ...handlerAuthority, + toolCall: makeStrReplaceCall({ + path, + atomic: false, + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = {', + allowMultiple: false, + }, + ], + }), + fileProcessingState, + logger: silentLogger, + requestClientToolCall: unreachableRequestClientToolCall, + requestOptionalFile: async () => fileContent, + writeToClient: noopWriteToClient, + }) + const value = result.output[0]?.value as + | { errorMessage?: string } + | undefined + expect(value?.errorMessage).toContain('Preflight') + expect(fileProcessingState.consecutiveStrReplaceFailuresByPath[path]).toBe( + 2, + ) + }) + + it('unique-only auto-reread: allowMultiple:true must fail closed under strictReadBeforeEdit', async () => { + const path = 'unique-only-autoreread.ts' + const fileContent = 'export const value = 1\nexport const value = 1\n' + const fileProcessingState = getFileProcessingValues({ + strictReadBeforeEdit: true, + }) + let applied = false + const result = await handleStrReplace({ + previousToolCallFinished: Promise.resolve(), + ...handlerAuthority, + toolCall: makeStrReplaceCall({ + path, + atomic: false, + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = 2', + allowMultiple: true, + }, + ], + }), + fileProcessingState, + logger: silentLogger, + requestClientToolCall: async () => { + applied = true + return [] as any + }, + requestOptionalFile: async () => fileContent, + writeToClient: noopWriteToClient, + }) + expect(applied).toBe(false) + const value = result.output[0]?.value as + | { errorMessage?: string; errorCode?: string } + | undefined + expect(value?.errorCode).toBe('fresh_read_required') + expect(String(value?.errorMessage)).toMatch(/read_files|basedOnRead|fresh/i) + expect( + fileProcessingState.consecutiveStrReplaceFailuresByPath[path], + ).toBeUndefined() + }) + + it('structuralRecovery bypasses circuit breaker on clean success and clears budget', async () => { + const path = 'recovery-bypass.ts' + const fileContent = 'export const value = 1\n' + const fileProcessingState = getFileProcessingValues({ + consecutiveStrReplaceFailuresByPath: { [path]: 5 }, + strictReadBeforeEdit: false, + }) + const result = await handleStrReplace({ + previousToolCallFinished: Promise.resolve(), + ...handlerAuthority, + toolCall: makeStrReplaceCall({ + path, + atomic: false, + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = 2', + allowMultiple: false, + }, + ], + }), + fileProcessingState, + logger: silentLogger, + structuralRecovery: true, + requestClientToolCall: confirmedRequestClientToolCall({ + [path]: 'export const value = 2\n', + }), + requestOptionalFile: async () => fileContent, + writeToClient: noopWriteToClient, + }) + const value = result.output[0]?.value as + | { errorMessage?: string } + | undefined + expect(value?.errorMessage).toBeUndefined() + expect( + fileProcessingState.consecutiveStrReplaceFailuresByPath[path], + ).toBeUndefined() + }) + + it('structuralRecovery releases the failure budget even when the recovery edit fails', async () => { + // RF-4: the budget was only released on the successful apply path, so a + // FAILED recovery edit left the counter pinned at the limit and every + // subsequent recovery attempt was refused by the breaker despite + // structuralRecovery being an explicit bypass. A failed recovery edit must + // also release the budget so the recovery path is not self-blocking. + const path = 'recovery-failure-releases-budget.ts' + const fileContent = 'export const value = 1\n' + const fileProcessingState = getFileProcessingValues({ + consecutiveStrReplaceFailuresByPath: { [path]: 5 }, + strictReadBeforeEdit: false, + }) + + const result = await handleStrReplace({ + previousToolCallFinished: Promise.resolve(), + ...handlerAuthority, + toolCall: makeStrReplaceCall({ + path, + atomic: false, + replacements: [ + { + oldString: 'export const absent = 999', + newString: 'export const absent = 1000', + allowMultiple: false, + }, + ], + }), + fileProcessingState, + logger: silentLogger, + structuralRecovery: true, + requestClientToolCall: unreachableRequestClientToolCall, + requestOptionalFile: async () => fileContent, + writeToClient: noopWriteToClient, + }) + + const value = result.output[0]?.value as + | { errorMessage?: string } + | undefined + // structuralRecovery bypasses the entry breaker, so the call reaches + // processStrReplace and reports the real no-match failure. + expect(value?.errorMessage ?? '').not.toMatch( + /^str_replace circuit breaker:/, + ) + expect(value?.errorMessage).toBeDefined() + // Budget released despite the failure: the next recovery attempt is not + // refused by the breaker. + expect( + fileProcessingState.consecutiveStrReplaceFailuresByPath[path], + ).toBeUndefined() + }) + + it('auto-reread authorizes a valid EMPTY file instead of blocking on fresh_read_required', async () => { + // RF-2: the auto-reread hash gate must not treat an empty file as "no + // observable content". getContentHash('') is a real hash string, so the + // gate keys off `=== undefined` rather than falsiness. An empty file is a + // legitimately readable file: auto-reread must authorize this attempt and + // let processStrReplace report the genuine no-match, NOT fail closed up + // front with the "read_files must authorize" block. + const path = 'empty-file-autoreread.ts' + const emptyFileContent = '' + const fileProcessingState = getFileProcessingValues({ + strictReadBeforeEdit: true, + }) + + const result = await handleStrReplace({ + previousToolCallFinished: Promise.resolve(), + ...handlerAuthority, + toolCall: makeStrReplaceCall({ + path, + atomic: false, + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = 2', + allowMultiple: false, + }, + ], + }), + fileProcessingState, + logger: silentLogger, + requestClientToolCall: unreachableRequestClientToolCall, + requestOptionalFile: async () => emptyFileContent, + writeToClient: noopWriteToClient, + }) + + const value = result.output[0]?.value as + | { errorMessage?: string } + | undefined + const errorMessage = String(value?.errorMessage ?? '') + // Proof the empty file was authorized and processing actually ran: the + // auto-reread-failed recovery suffix is only appended after the gate + // authorized the attempt. + expect(errorMessage).toContain('Auto-re-read once failed to apply') + // The up-front "cannot authorize" block must NOT have fired for a valid + // (if empty) file. + expect(errorMessage).not.toContain('must authorize the file before editing') + }) }) diff --git a/packages/agent-runtime/src/tools/handlers/tool/edit-transaction.ts b/packages/agent-runtime/src/tools/handlers/tool/edit-transaction.ts index 8ea2ef882d..6b85bd3be8 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/edit-transaction.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/edit-transaction.ts @@ -931,27 +931,45 @@ export const handleEditTransaction = (async ( } if ('error' in transactionResult) { - const failureText = transactionResult.failures - .map((failure) => failure.errorMessage) - .join('\n') - const requiresFreshCapability = - transactionResult.failures.some( - (failure) => - typeof failure.failureKind === 'string' && - failure.failureKind.startsWith('capability'), - ) || - /different project, path, or agent run|Invalid basedOnRead|readCapability-covered (?:symbol )?content is stale|normalized capability metadata does not match|readCapability does not cover the exact original symbol replacement span/i.test( - failureText, - ) - const isMatchOrAtomicAbort = - transactionResult.failures.some( - (failure) => - failure.failureKind === 'no_match' || - failure.failureKind === 'preflight_failed', - ) || - /not an exact contiguous match|Atomic str_replace batch aborted|Found \d+ occurrences of|only \d+ exact occurrence\(s\) of the oldString exist/i.test( - failureText, - ) + // Single source of truth: processEditTransaction classifies every failure it + // reports, so the handler keys off failureKind only. Re-deriving these flags + // from failure prose would be a second copy of that classification (free to + // drift), and any error text that merely quotes a marker would be + // misclassified. + const failureKinds = transactionResult.failures.map( + (failure) => failure.failureKind, + ) + const requiresFreshCapability = failureKinds.some( + (kind) => typeof kind === 'string' && kind.startsWith('capability'), + ) + // An anchored scope mismatch is a per-path targeting mistake: the supplied + // capability was fresh, no file changed, and only the offending path's read + // scope is wrong. Narrow the invalidation blast radius so the other + // transaction targets keep their read authorization. + const isAnchorScopeMismatch = failureKinds.some( + (kind) => kind === 'anchor_scope_mismatch', + ) + const isMatchOrAtomicAbort = failureKinds.some( + (kind) => + kind === 'no_match' || + kind === 'preflight_failed' || + kind === 'anchor_scope_mismatch', + ) + // Only the paths that actually failed with an anchored scope mismatch may + // keep the narrowed invalidation; a co-failing unrelated path must never be + // pulled in, even if a future result reports more than one failure. + const anchorScopeMismatchPaths = Array.from( + new Set( + transactionResult.failures + .filter((failure) => failure.failureKind === 'anchor_scope_mismatch') + .map((failure) => failure.path) + .filter((path) => Boolean(path)), + ), + ) + const narrowInvalidationToFailingPaths = + isAnchorScopeMismatch && + !requiresFreshCapability && + anchorScopeMismatchPaths.length > 0 // Match / atomic-batch aborts and capability failures both require one new // snapshot for every transaction target so multi-file retries cannot reuse // other paths from memory. Pure syntax failures never reach this branch. @@ -971,15 +989,25 @@ export const handleEditTransaction = (async ( input: { paths: uniquePaths }, ...(isMatchOrAtomicAbort && !requiresFreshCapability ? { - preferredStrategy: /replace_range with its readCapability|Do not reconstruct huge blocks from memory|No useful candidate ranges found/i.test( - failureText, - ) + // An anchored scope mismatch needs a capability that actually + // covers the target lines, so replace_range beats a shorter + // oldString. Same rule as buildTransactionRecovery, keyed off + // the shared failureKind rather than a second prose regex. + preferredStrategy: isAnchorScopeMismatch ? ('replace_range' as const) : ('smaller_oldString' as const), } : {}), } : undefined) + const scopedRecovery = + recovery && narrowInvalidationToFailingPaths + ? { + ...recovery, + paths: anchorScopeMismatchPaths, + input: { paths: anchorScopeMismatchPaths }, + } + : recovery const errorCode = transactionResult.errorCode ?? (requiresFreshCapability @@ -991,7 +1019,9 @@ export const handleEditTransaction = (async ( : undefined) invalidatePreparedEditPaths({ fileProcessingState, - paths: uniquePaths, + paths: narrowInvalidationToFailingPaths + ? anchorScopeMismatchPaths + : uniquePaths, revokeReadAuthorization: requiresFreshRead, requiresFreshRead, ...(requiresFreshRead @@ -1004,11 +1034,15 @@ export const handleEditTransaction = (async ( : {}), }) - const multiTargetRecoveryProse = requiresFreshRead + const multiTargetRecoveryProse = narrowInvalidationToFailingPaths ? [ - `Atomic recovery requires fresh read state for every transaction target in this run: ${uniquePaths.join(', ')}. Re-read all targets and rebuild the complete transaction from one coherent snapshot; do not refresh only the first failed path or replay any other stale token/oldString from memory.`, + `Only ${anchorScopeMismatchPaths.join(', ')} lost read authorization; every other transaction target retains valid read state. Re-read a range that contains the target lines for that path only, then resend the whole transaction because no files were changed.`, ] - : [] + : requiresFreshRead + ? [ + `Atomic recovery requires fresh read state for every transaction target in this run: ${uniquePaths.join(', ')}. Re-read all targets and rebuild the complete transaction from one coherent snapshot; do not refresh only the first failed path or replay any other stale token/oldString from memory.`, + ] + : [] return { output: [ @@ -1021,7 +1055,7 @@ export const handleEditTransaction = (async ( failures: transactionResult.failures, ...(requiresFreshRead && { requiresFreshRead: true }), ...(errorCode && { errorCode }), - ...(recovery && { recovery }), + ...(scopedRecovery && { recovery: scopedRecovery }), }, }, ], @@ -1187,6 +1221,29 @@ export const handleEditTransaction = (async ( }) clientChanges.sort((a, b) => a.index - b.index) + // Idempotent-cleanup contract: when every content edit resolved to an + // already-applied skipIfMissing deletion there is nothing for the client to + // apply. Report the preflight success and its skip messages instead of + // sending an empty change list, and leave read authorization state untouched + // because no file changed. + if (clientChanges.length === 0) { + return { + output: [ + { + type: 'json', + value: { + message: transactionResult.message, + files: transactionResult.files.map((file) => ({ + path: file.path, + patch: file.patch, + messages: file.messages, + })), + }, + }, + ], + } + } + // Only paths that produced an actual client change can emit an `applied` // action, so scope the positive-evidence confirmation set to those paths. // A content edit that resolved to a no-op is excluded from `clientChanges` diff --git a/packages/agent-runtime/src/tools/handlers/tool/str-replace.ts b/packages/agent-runtime/src/tools/handlers/tool/str-replace.ts index 6c8c21703a..e72a081213 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/str-replace.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/str-replace.ts @@ -1,5 +1,3 @@ -import { getContentHash } from '@codebuff/common/util/content-hash' - import { formatUnsafeToolPathError, hasWholeFileReadAuthorization, @@ -22,7 +20,7 @@ import { } from '../../../util/preflight-syntax-validation' import type { CodebuffToolHandlerFunction } from '../handler-function-type' -import type { FileProcessing, FileProcessingState } from './write-file' +import type { FileProcessingState } from './write-file' import type { ClientToolCall, CodebuffToolCall, @@ -42,7 +40,34 @@ import type { ProjectFileContext } from '@codebuff/common/util/file' // mid-refactor lockout friction while still forcing tool switches. const STR_REPLACE_MAX_CONSECUTIVE_FAILURES = 5 -const NEAR_MATCH_AUTOCORRECT_MARKER = 'auto-corrected a near-match edit' +const STR_REPLACE_CIRCUIT_BREAKER_TOOL_GUIDANCE = + 'rewrite_symbol for a whole symbol, replace_range with a fresh readCapability for a known block, or write_file' + +// Fix C lifecycle: consecutiveStrReplaceFailuresByPath is turn-scoped. A fresh +// FileProcessingState is created per turn via getFileProcessingValues() and +// hydrated from the durable agent state at processStream/runProgrammaticStep +// boundaries, so the budget resets at each turn boundary. The only intra-turn +// eviction is the structuralRecovery path below (set only by rewrite_symbol +// for whole-symbol recovery), which deletes the entry on any clean success +// when the flag is set to allow recovery edits to proceed; all other paths +// leave the budget non-draining on clean success to prevent fail↔success +// oscillation from evading the breaker. + +// Centralized helper for the per-path failure budget. Deduplicates the +// increment that previously appeared in both the hard-error and the +// autocorrect/partial-success branches (RF-6). +function incrementStrReplaceFailureBudget( + state: FileProcessingState, + path: string, +): number { + const current = state.consecutiveStrReplaceFailuresByPath[path] ?? 0 + // Stored value is capped at MAX+5 to prevent unbounded growth while + // diagnostic messages are capped at MAX to stay aligned with the breaker + // threshold (5). Callers should cap display counts to MAX. + const next = Math.min(current + 1, STR_REPLACE_MAX_CONSECUTIVE_FAILURES + 5) + state.consecutiveStrReplaceFailuresByPath[path] = next + return next +} export const handleStrReplace = (async ( params: { @@ -114,6 +139,10 @@ export const handleStrReplace = (async ( !structuralRecovery && consecutiveFailures >= STR_REPLACE_MAX_CONSECUTIVE_FAILURES ) { + const displayFailures = Math.min( + consecutiveFailures, + STR_REPLACE_MAX_CONSECUTIVE_FAILURES, + ) return { output: [ { @@ -121,10 +150,15 @@ export const handleStrReplace = (async ( value: { file: path, errorMessage: [ - `str_replace circuit breaker: ${consecutiveFailures} failed or auto-corrected attempts on \`${path}\` in this turn.`, + `str_replace circuit breaker: ${displayFailures} failed or auto-corrected attempts on \`${path}\` in this turn.`, 'Continuing to retry str_replace on this path is likely to corrupt the file.', - 'Next: use rewrite_symbol for an entire function/method/type, replace_range with a fresh readCapability for a known block, or write_file to reconstruct the whole file. Raw str_replace remains blocked for this path until the next turn.', + `Next: use ${STR_REPLACE_CIRCUIT_BREAKER_TOOL_GUIDANCE} to reconstruct the whole file. Raw str_replace remains blocked for this path until the next turn.`, ].join('\n'), + errorCode: 'str_replace_circuit_breaker', + recovery: { + tool: 'read_files', + input: { paths: [path] }, + }, }, }, ], @@ -156,7 +190,7 @@ export const handleStrReplace = (async ( // filesystem stub does not immediately reflect them. Across turns there is // no prior promise, so the disk read below is the external-change boundary. // Auto-reread-once for strict auth miss also uses this load (one attempt). - let latestContent = hasAnyReadCapability + let latestContent: string | null = hasAnyReadCapability ? await requestOptionalFile({ ...params, filePath: path }) : previousEdit ? await previousEdit.then((maybeResult) => @@ -194,22 +228,37 @@ export const handleStrReplace = (async ( !structuralRecovery if (needsAuthWithoutCapability && replacementsAreUniqueOnly) { autoRereadAttempted = true - if (typeof latestContent !== 'string') { - // Prefer a fresh disk load when previous-edit chain had no content. - latestContent = await requestOptionalFile({ ...params, filePath: path }) - } - if (typeof latestContent === 'string') { + // Reuse the content already loaded above when it came from this same + // handler pass — no prior same-turn edit and no failed-edit recovery — so + // the load is the current disk state from this client round trip and a + // second requestOptionalFile would be duplicate I/O. Re-fetch when a prior + // same-turn edit or a failed-edit recovery means the loaded bytes may not + // reflect current disk content. + const shouldReuseLatestContent = + !previousEdit && + typeof latestContent === 'string' && + !recoveringFromFailedEdit + const freshDiskContent = shouldReuseLatestContent + ? latestContent + : await requestOptionalFile({ ...params, filePath: path }) + if (typeof freshDiskContent === 'string') { + // Any string (including '' for an empty file) is observable disk content + // and authorizes this attempt. A missing file falls through to the + // fail-closed branch below. + latestContent = freshDiskContent // In-process only: authorize this str_replace call; no durable sticky mint. // The helper may drop failed-edit markers but keeps context_compacted. clearEditRereadRequirement(fileProcessingState, path) hadFreshWholeFileAuthorization = true } else { + // Entry condition guarantees !hasStoredWholeFileAuthorization here, so + // there is no stale sticky hash to report. const authorizationError = strictEditAuthorizationError({ fileProcessingState, path, toolName: 'str_replace', hasFreshWholeFileAuthorization: false, - authorizationWasStale: hasStoredWholeFileAuthorization, + authorizationWasStale: false, }) return { output: [ @@ -221,7 +270,7 @@ export const handleStrReplace = (async ( authorizationError?.errorMessage ?? `str_replace blocked for ${path}: read_files must authorize the file before editing.`, errorCode: 'fresh_read_required', - recovery: { + recovery: authorizationError?.recovery ?? { tool: 'read_files', input: { paths: [path] }, }, @@ -283,7 +332,28 @@ export const handleStrReplace = (async ( } } - const newPromise: Promise> = processStrReplace({ + // Single-sourced idempotent-cleanup signal: processStrReplace sets hadNoOpSkip ONLY + // on the all-skip success branch (every replacement resolved to an + // already-applied skipIfMissing deletion, so the patch is empty) and + // edit_transaction keys off the same structured flag. Const-captured after await + // from strReplaceResult to avoid mutable closure state. + type StrReplaceResultWithMetadata = Awaited< + ReturnType + > & { + // Required, not optional: the terminal `.then` below attaches + // `toolCallId` on every branch (spread success/error result and the + // preflight-failure object), and `FileProcessing` requires it. Marking it + // optional here breaks assignability to `Promise` for + // promisesByPath/allPromises and to postStreamProcessing. + toolCallId: string + preflightSyntaxError?: boolean + errorCode?: string + recovery?: unknown + // failureKind is part of processStrReplace error union; re-exposed here + // so typed access does not require an untyped cast. + failureKind?: string + } + const newPromise: Promise = processStrReplace({ path, replacements, atomic, @@ -336,7 +406,14 @@ export const handleStrReplace = (async ( fileProcessingState.allPromises.push(newPromise) const strReplaceResult = await newPromise - let hadAutoCorrect = false + const everyReplacementWasNoOpSkip = + 'content' in strReplaceResult && + 'hadNoOpSkip' in strReplaceResult && + strReplaceResult.hadNoOpSkip === true + const hadAutoCorrect = + !('error' in strReplaceResult) && + 'hadAutoCorrect' in strReplaceResult && + strReplaceResult.hadAutoCorrect === true if ('error' in strReplaceResult) { // A preflight syntax failure is semantically different from a stale-anchor // failure: the agent's oldString was fine, the new content just had a @@ -344,10 +421,13 @@ export const handleStrReplace = (async ( // the agent only needs to fix the syntax, not re-read the file or switch // tools. (Fix C circuit breaker only counts real processing failures.) if (!strReplaceResult.preflightSyntaxError) { + const failureKind = + 'failureKind' in strReplaceResult + ? strReplaceResult.failureKind + : undefined const requiresFreshCapability = - /(?:readCapability|basedOnRead).*(?:stale|different project, path, or agent run)|(?:stale|different project, path, or agent run).*(?:readCapability|basedOnRead)/is.test( - strReplaceResult.error, - ) + failureKind === 'capability_scope' || + failureKind === 'anchor_scope_mismatch' if (requiresFreshCapability) { markEditRequiresFreshRead({ fileProcessingState, @@ -355,6 +435,24 @@ export const handleStrReplace = (async ( reason: 'stale_capability', sourceTool: 'str_replace', }) + } else if ( + getEditRereadRequirement(fileProcessingState, path)?.reason === + 'context_compacted' + ) { + // RF-3: a failed edit under compaction must still revoke the sticky + // whole-file authorization, otherwise a later write_file could + // whole-file overwrite off a hash the model can no longer see. The + // reason is NOT clobbered: markEditRequiresFreshRead retains an + // existing context_compacted reason (and its original sourceTool) and + // never downgrades it to the weaker reason passed here. The marker + // stays authoritative until a complete whole-file read_files grant or + // an explicit whole-file basedOnRead clears it. + markEditRequiresFreshRead({ + fileProcessingState, + path, + reason: 'stale_capability', + sourceTool: 'str_replace', + }) } // Internal auto-reread content may authorize only this attempt. A failed // attempt must recover through a complete, model-visible read_files read. @@ -362,45 +460,55 @@ export const handleStrReplace = (async ( strReplaceResult.error = [ strReplaceResult.error, `Auto-re-read once failed to apply. Call read_files with paths: ["${path}"] for a complete read before retrying str_replace.`, - JSON.stringify({ - recovery: { - tool: 'read_files', - input: { paths: [path] }, - }, - }), ].join('\n') + strReplaceResult.errorCode = 'fresh_read_required' + strReplaceResult.recovery = { + tool: 'read_files', + input: { paths: [path] }, + } } // Deterministic no-match/ambiguity preflight failures do not mutate the // client and therefore preserve any valid read authorization. - // Fix C: a hard error consumes the per-path failure budget. - fileProcessingState.consecutiveStrReplaceFailuresByPath[path] = - (fileProcessingState.consecutiveStrReplaceFailuresByPath[path] ?? 0) + 1 - if ( - fileProcessingState.consecutiveStrReplaceFailuresByPath[path] >= - STR_REPLACE_MAX_CONSECUTIVE_FAILURES - ) { - strReplaceResult.error = [ - strReplaceResult.error, - `str_replace retry limit reached for \`${path}\` after ${fileProcessingState.consecutiveStrReplaceFailuresByPath[path]} failed or auto-corrected attempts in this turn.`, - 'Do not retry another remembered str_replace batch. Switch to rewrite_symbol for a whole symbol, replace_range with a fresh readCapability for a known block, or write_file when reconstructing the whole file is safer.', - ].join('\n\n') + // structuralRecovery is an explicit breaker bypass: release the budget + // and skip the increment entirely, so the emitted message and the stored + // count always agree (a released budget never carries a limit warning). + if (structuralRecovery) { + delete fileProcessingState.consecutiveStrReplaceFailuresByPath[path] + } else { + const consecutiveAfterError = incrementStrReplaceFailureBudget( + fileProcessingState, + path, + ) + if (consecutiveAfterError >= STR_REPLACE_MAX_CONSECUTIVE_FAILURES) { + const displayCount = Math.min( + consecutiveAfterError, + STR_REPLACE_MAX_CONSECUTIVE_FAILURES, + ) + strReplaceResult.error = [ + strReplaceResult.error, + `str_replace retry limit reached for \`${path}\` after ${displayCount} failed or auto-corrected attempts in this turn.`, + `Do not retry another remembered str_replace batch. Switch to ${STR_REPLACE_CIRCUIT_BREAKER_TOOL_GUIDANCE} when reconstructing the whole file is safer.`, + ].join('\n\n') + } } } } else { - // Fix C: an auto-corrected near-match is a weak/suspect outcome and also - // counts toward the circuit breaker. Clean exact-match success leaves the - // budget intact (non-draining) rather than full-reset or drain-by-1 so - // fail↔success oscillation cannot evade the breaker. Full reset still only - // happens at the next turn (or structural recovery below). - hadAutoCorrect = strReplaceResult.messages.some((msg) => - msg.includes(NEAR_MATCH_AUTOCORRECT_MARKER), - ) if (hadAutoCorrect || (strReplaceResult.failedReplacementCount ?? 0) > 0) { - fileProcessingState.consecutiveStrReplaceFailuresByPath[path] = - (fileProcessingState.consecutiveStrReplaceFailuresByPath[path] ?? 0) + 1 + const consecutiveAfterSuccess = incrementStrReplaceFailureBudget( + fileProcessingState, + path, + ) + if (consecutiveAfterSuccess >= STR_REPLACE_MAX_CONSECUTIVE_FAILURES) { + const displayCount = Math.min( + consecutiveAfterSuccess, + STR_REPLACE_MAX_CONSECUTIVE_FAILURES, + ) + const limitWarning = `str_replace retry limit reached for \`${path}\` after ${displayCount} failed or auto-corrected attempts in this turn. Do not retry another remembered str_replace batch. Switch to ${STR_REPLACE_CIRCUIT_BREAKER_TOOL_GUIDANCE} when reconstructing the whole file is safer.` + // Symmetric with error-path warning: surface limit reached on success + // autocorrect/partial path as well (RF-4). + strReplaceResult.messages.push(limitWarning) + } } - // else: clean exact-match success — leave consecutiveStrReplaceFailuresByPath - // unchanged so prior failures keep climbing toward the limit. // Strict read-before-edit: read authorization is sticky once granted by // read_files or write_file. Successful edits on the same path remain // authorized for subsequent edits; only a failed edit (which sets @@ -408,6 +516,37 @@ export const handleStrReplace = (async ( // with a fresh basedOnRead capability) re-enables the strict gate. } + // Zero-change guard, mirroring edit_transaction's `clientChanges.length === 0` + // branch. processStrReplace reports an all-skip idempotent cleanup as success + // with an empty patch; postStreamProcessing branches on `patch ? patch : file` + // and would turn that into a whole-file write of unchanged content. Key off the + // structured all-skip flag (the same contract edit_transaction uses) AND an + // empty patch, so an unrelated empty-patch success is never reported as an + // already-applied skipIfMissing deletion and a MIXED batch (a skip co-present + // with a replacement that really applied) still reaches the client with its + // applied content. Return the skip messages without calling the client, and + // leave read authorization state untouched because no file changed. + if ( + everyReplacementWasNoOpSkip && + 'content' in strReplaceResult && + !strReplaceResult.patch + ) { + return { + output: [ + { + type: 'json', + value: { + file: path, + message: [ + ...strReplaceResult.messages, + 'No file changes were applied because every requested replacement was an already-applied skipIfMissing deletion.', + ].join('\n\n'), + }, + }, + ], + } + } + const application = await coordinateEditApplication<'str_replace'>({ toolName: 'str_replace', fileProcessingState, @@ -506,5 +645,23 @@ export const handleStrReplace = (async ( ].join('\n\n') } + if ('error' in strReplaceResult) { + const maybeErrorCode = strReplaceResult.errorCode + const maybeRecovery = strReplaceResult.recovery + if ( + maybeErrorCode && + firstResult.type === 'json' && + firstResult.value && + typeof firstResult.value === 'object' + ) { + ;(firstResult.value as Record).errorCode = + maybeErrorCode + if (maybeRecovery !== undefined) { + ;(firstResult.value as Record).recovery = + maybeRecovery + } + } + } + return { output: clientToolResult } }) satisfies CodebuffToolHandlerFunction<'str_replace'> diff --git a/packages/agent-runtime/src/util/__tests__/agent-tool-names.test.ts b/packages/agent-runtime/src/util/__tests__/agent-tool-names.test.ts index 7745c2b180..df0341b9cf 100644 --- a/packages/agent-runtime/src/util/__tests__/agent-tool-names.test.ts +++ b/packages/agent-runtime/src/util/__tests__/agent-tool-names.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test' import { getEffectiveAgentToolNames } from '../agent-tool-names' +import { ALLOW_ALL_TIER_TOOLS } from '../base2-tool-tiers' import type { AgentTemplate } from '../../templates/types' @@ -57,4 +58,63 @@ describe('getEffectiveAgentToolNames', () => { ), ).toEqual(['read_files']) }) + + describe('progressive tool disclosure', () => { + it('ignores persisted unlocks when the canary is explicitly off', () => { + expect( + getEffectiveAgentToolNames( + template({ + toolNames: ['read_files', 'run_terminal_command'], + programmaticConfig: { progressiveToolDisclosure: false }, + }), + { unlockedToolTiers: ['implement'] }, + ), + ).toEqual(['read_files', 'run_terminal_command']) + }) + + it('leaves the template surface unchanged for absent or empty unlocks', () => { + const agentTemplate = template({ + toolNames: ['read_files', 'run_terminal_command'], + }) + expect(getEffectiveAgentToolNames(agentTemplate)).toEqual([ + 'read_files', + 'run_terminal_command', + ]) + expect( + getEffectiveAgentToolNames(agentTemplate, { unlockedToolTiers: [] }), + ).toEqual(['read_files', 'run_terminal_command']) + }) + + it('fails closed when a non-empty unlock list has no published fullToolSurface', () => { + expect( + getEffectiveAgentToolNames(template({ toolNames: ['read_files'] }), { + unlockedToolTiers: ['implement'], + }), + ).toEqual(['read_files']) + }) + + it('appends only tier tools the published fullToolSurface admits', () => { + expect( + getEffectiveAgentToolNames( + template({ + toolNames: ['read_files'], + programmaticConfig: { fullToolSurface: ['edit_transaction'] }, + }), + { unlockedToolTiers: ['implement'] }, + ), + ).toEqual(['read_files', 'edit_transaction']) + }) + + it('admits every unlocked tier tool for the ALLOW_ALL sentinel', () => { + const names = getEffectiveAgentToolNames( + template({ + toolNames: ['read_files'], + programmaticConfig: { fullToolSurface: ALLOW_ALL_TIER_TOOLS }, + }), + { unlockedToolTiers: ['implement'] }, + ) + expect(names).toContain('edit_transaction') + expect(names).toContain('run_terminal_command') + }) + }) }) diff --git a/packages/agent-runtime/src/util/agent-tool-names.ts b/packages/agent-runtime/src/util/agent-tool-names.ts index 98257f532a..03806b1109 100644 --- a/packages/agent-runtime/src/util/agent-tool-names.ts +++ b/packages/agent-runtime/src/util/agent-tool-names.ts @@ -1,7 +1,28 @@ -import { filterByUnlockedTiers } from './base2-tool-tiers' +import { + ALLOW_ALL_TIER_TOOLS, + filterByUnlockedTiers, + type TierToolCeiling, +} from './base2-tool-tiers' import type { AgentTemplate } from '../templates/types' +/** + * Resolve a template's published `programmaticConfig.fullToolSurface` into the + * `templateAllows` ceiling `filterByUnlockedTiers` requires. A missing or + * unrecognized value fails CLOSED; allow-all must be the explicit + * `ALLOW_ALL_TIER_TOOLS` sentinel (see ./base2-tool-tiers.ts). + */ +function resolveTierCeiling(rawSurface: unknown): TierToolCeiling { + if (rawSurface === ALLOW_ALL_TIER_TOOLS) return ALLOW_ALL_TIER_TOOLS + if (!Array.isArray(rawSurface)) return () => false + // Set (built once) rather than a per-candidate array scan: the append loop in + // filterByUnlockedTiers membership-tests every unlocked tier tool. + const fullSurface = new Set( + rawSurface.filter((name): name is string => typeof name === 'string'), + ) + return (name) => fullSurface.has(name) +} + /** * Return the tools an agent is actually allowed to expose at runtime. * @@ -12,25 +33,19 @@ import type { AgentTemplate } from '../templates/types' * capability is intentionally narrow: it adds no filesystem, process, network, * or delegation authority. * - * Progressive tool disclosure contract for `agentState.unlockedToolTiers`: - * - progressive canary off (`programmaticConfig.progressiveToolDisclosure - * === false`) → always return the template's toolNames unchanged. Stale - * non-empty unlocks from a prior canary-on run MUST NOT re-activate - * CORE+tiers filtering on resume/canary-off (would permanently shrink a - * full-surface template). - * - progressive on / unspecified + absent or empty array → return the - * template's toolNames unchanged (full mode-resolved surface for - * default-off / non-progressive agents; CORE-only static template for - * progressive base2 before any unlock). - * - progressive on / unspecified + non-empty array → narrow/expand to CORE - * plus those unlocked tiers, still capped by - * programmaticConfig.fullToolSurface when present. - * - * Empty must NOT trigger CORE filtering: resume/checkpoint consumers treat - * `unlockedToolTiers: []` the same as the field being absent (full template - * surface for non-progressive agents). Progressive core-only steps still work - * because base2's static template.toolNames is already CORE-only when the - * canary is on; publishing `[]` leaves that CORE list alone. + * Progressive tool disclosure: ./base2-tool-tiers.ts owns that contract (CORE + * membership, keep-vs-append behaviour, and the fail-closed `templateAllows` + * ceiling). This caller only decides *whether* to filter at all: + * - `programmaticConfig.progressiveToolDisclosure === false` → return the + * template's toolNames unchanged, even when a prior canary-on run persisted + * unlocks, so resume/canary-off cannot permanently shrink a full-surface + * template. + * - absent OR empty `agentState.unlockedToolTiers` → toolNames unchanged. + * Resume/checkpoint consumers treat `[]` the same as the field being + * absent; progressive base2 still works because its static template + * surface is already CORE-only before any unlock. + * - non-empty `unlockedToolTiers` → delegate to `filterByUnlockedTiers` with + * the ceiling resolved from `programmaticConfig.fullToolSurface`. * * Callers that gate model tool *execution* without agentState (notably the * tool executor) must pass a template whose `toolNames` already reflect this @@ -40,7 +55,7 @@ export function getEffectiveAgentToolNames( agentTemplate: AgentTemplate, agentState?: { unlockedToolTiers?: string[] }, ): string[] { - let names = [...agentTemplate.toolNames] + const names = [...agentTemplate.toolNames] const programmaticToolNames = agentTemplate.programmaticToolNames ?? [] if ( agentTemplate.outputMode === 'structured_output' && @@ -52,29 +67,16 @@ export function getEffectiveAgentToolNames( const programmaticConfig = agentTemplate.programmaticConfig as | { progressiveToolDisclosure?: unknown; fullToolSurface?: unknown } | undefined - // Explicit canary-off wins over any persisted unlock list. Resume after a - // canary-on session (or flipping the canary off mid-session) must keep the - // full mode-resolved template surface, not CORE + stale tiers. if (programmaticConfig?.progressiveToolDisclosure === false) { return names } const unlockedTiers = agentState?.unlockedToolTiers - // Absent OR empty → template surface unchanged (resume/checkpoint contract). - // Only a non-empty published tier list activates progressive CORE+tiers - // filtering/expansion, and only when progressive disclosure is not off. - if (Array.isArray(unlockedTiers) && unlockedTiers.length > 0) { - // Additive ceiling: the template's mode-resolved full surface, when the - // agent published one (base2 via programmaticConfig.fullToolSurface). - // Unlocked tier tools are only re-added when the full surface includes - // them, so plan-only / no-ask-user / fast mode gates are never widened. - const rawSurface = programmaticConfig?.fullToolSurface - const fullSurface = Array.isArray(rawSurface) - ? rawSurface.filter((name): name is string => typeof name === 'string') - : undefined - const templateAllows = fullSurface - ? (name: string) => fullSurface.includes(name) - : undefined - names = filterByUnlockedTiers(names, unlockedTiers, templateAllows) + if (!Array.isArray(unlockedTiers) || unlockedTiers.length === 0) { + return names } - return names + return filterByUnlockedTiers( + names, + unlockedTiers, + resolveTierCeiling(programmaticConfig?.fullToolSurface), + ) } diff --git a/packages/agent-runtime/src/util/base2-tool-tiers.ts b/packages/agent-runtime/src/util/base2-tool-tiers.ts index 69759814bd..b08624dfb2 100644 --- a/packages/agent-runtime/src/util/base2-tool-tiers.ts +++ b/packages/agent-runtime/src/util/base2-tool-tiers.ts @@ -1,32 +1,31 @@ /** - * Runtime-side mirror of the base2 progressive tool-tier constants in - * `agents/base2/tool-tiers.ts`. Kept local to `agent-runtime` because this - * package must not import from `agents/` (wrong dependency direction). The - * two lists must stay in sync: CORE is always available, and each tier maps - * to the extra tools it unlocks. + * Single owner of the base2 progressive tool-tier constants. Defined here + * (rather than in `agents/base2/tool-tiers.ts`) because this package must not + * import from `agents/` (wrong dependency direction); the base2 template + * consumes these constants, so there is no second list to keep in sync. CORE + * is always available, and each tier maps to the extra tools it unlocks. * - * Mode-gated tools (e.g. `run_terminal_command` is execute-plan only, - * `ask_user`/`write_todos` are mode/flag gated) are still governed by the - * template's own mode resolution — `filterByUnlockedTiers` only adds a tier - * tool when the template could legitimately expose it, so it cannot widen - * beyond the template's mode-appropriate ceiling. + * Canonical contract for progressive tool disclosure (other modules point here + * instead of restating it): + * - CORE is listed unconditionally and is deliberately broader than any one + * mode's surface: `ask_user`/`write_todos` appear here even though + * fast/plan-only base2 withholds them. The template's own mode resolution + * (`modeAllowsTool` in agents/base2/tool-tiers.ts) is what gates those. + * - `filterByUnlockedTiers` only KEEPS names already present in its input and + * only APPENDS tier tools its required `templateAllows` ceiling admits, so + * it cannot widen beyond the template's mode-appropriate surface. The + * permissive branch is never implicit: allow-all requires the explicit + * `ALLOW_ALL_TIER_TOOLS` sentinel. + * - base2 pins `programmaticConfig.progressiveToolDisclosure: false`, so + * `getEffectiveAgentToolNames` returns early and base2 never reaches + * `filterByUnlockedTiers`. This runtime ceiling is therefore dormant for + * base2 and binds only a caller that does enable tier filtering. */ import type { ToolName } from '@codebuff/common/tools/constants' /** * Base2 CORE tool names — always available when progressive disclosure is on. - * - * Semantically broader than the template's CORE surface: `ask_user` and - * `write_todos` are listed unconditionally here, but fast/plan-only - * progressive base2 never exposes them (they are mode-gated in the template's - * buildArray). `filterByUnlockedTiers` only *keeps* names already present in - * its input, and `base2` always passes the template's full surface as - * `templateAllows` to cap tier adds, so this never widens the surfaced set. - * The unconditional list is a deliberate ceiling: a runtime-side CORE-only - * path MUST still pass `templateAllows` (or otherwise apply the same mode - * gates), or it could expose `ask_user`/`write_todos` in a mode that forbids - * them. */ export const BASE2_CORE_TOOL_NAMES: readonly ToolName[] = [ 'spawn_agents', @@ -49,8 +48,19 @@ export const BASE2_CORE_TOOL_NAMES: readonly ToolName[] = [ export type ToolTier = 'core' | 'implement' | 'audit' | 'media_3d' | 'job_extra' +/** + * The single name for "a tier that can actually be unlocked". CORE is + * unconditional, so it is deliberately not expressible here. Consumers — + * including the base2 template's `unlockedTiers` option — alias this type + * instead of re-deriving an equivalent one. + */ +export type UnlockedToolTier = Exclude + /** Tools unlocked by each non-core base2 tier. */ -export const BASE2_TIER_TOOL_NAMES: Record, readonly ToolName[]> = { +export const BASE2_TIER_TOOL_NAMES: Record< + UnlockedToolTier, + readonly ToolName[] +> = { implement: [ 'edit_transaction', 'create_plan', @@ -78,6 +88,24 @@ export const BASE2_TIER_TOOL_NAMES: Record, readonly T job_extra: ['kill_job'], } +/** + * Explicit opt-out sentinel for the `templateAllows` ceiling: pass this to + * admit every unlocked tier tool. The permissive branch must be chosen + * deliberately — a caller that simply has no ceiling to pass (e.g. a + * progressive template omitting `programmaticConfig.fullToolSurface`) must not + * fail open into allow-all, or it would unlock every tier tool + * (`run_terminal_command` included) with no mode ceiling. + */ +export const ALLOW_ALL_TIER_TOOLS = 'allow-all' as const + +/** + * Mode ceiling for the tier-tool append path: a membership predicate over the + * template's mode-resolved full surface, or the explicit allow-all sentinel. + */ +export type TierToolCeiling = + | ((name: string) => boolean) + | typeof ALLOW_ALL_TIER_TOOLS + /** Cached set of all tier-gated tool names — avoids rebuilding per call. */ const TIER_GATED: ReadonlySet = new Set( Object.values(BASE2_TIER_TOOL_NAMES).flat(), @@ -87,35 +115,40 @@ const TIER_GATED: ReadonlySet = new Set( * Compute the effective base2 tool surface for progressive tool disclosure: * the template's CORE-only list plus the tools for each unlocked tier. * - * The template's `toolNames` is the static, mode-resolved list (CORE-only - * when the canary built it). This helper: + * The template's `toolNames` is the static, mode-resolved list (CORE-only when + * the canary built it). This helper: * - keeps every template name that is CORE, non-tier, or belongs to an * unlocked tier (preserving template order), and - * - appends any newly unlocked tier tool not already present (in canonical - * tier order), so tiers unlock onto a core-only static template. + * - appends any newly unlocked tier tool `templateAllows` admits and the + * input list did not already contain (in canonical tier order), so tiers + * unlock onto a core-only static template. + * + * `unlockedTiers` is `readonly unknown[]` because it carries persisted + * `AgentState.unlockedToolTiers` state: non-string, `'core'`, unknown, and + * duplicate entries are ignored. * - * A tier tool that the template could not expose in this mode (e.g. - * `edit_transaction` in plan-only mode) is NOT added: it only appears when - * the template's mode resolution would have included it in the full surface. - * Callers pass the template's full-surface membership via `templateAllows` - * when they need that ceiling; by default every tier tool is allowed. + * `templateAllows` is the mode ceiling and is REQUIRED so a new caller cannot + * silently widen past its mode gates. Pass the `ALLOW_ALL_TIER_TOOLS` sentinel + * to deliberately opt out (allow every tier tool), which is safe just for + * callers whose input list carries no mode gates to preserve; there is no + * implicit allow-all for a missing/unknown ceiling. * * Note: an *empty* `unlockedTiers` array here means CORE-only filtering of the * input list. Higher-level callers (`getEffectiveAgentToolNames`) must NOT * invoke this helper for absent/empty `agentState.unlockedToolTiers` — that - * persisted-state contract means "leave the template surface unchanged". - * Callers must also skip this helper when progressive disclosure is explicitly - * off on the template, even if a non-empty unlock list was persisted from a - * prior canary-on run (resume/canary-off must not permanently shrink the + * persisted-state contract means "leave the template surface unchanged" — and + * must also skip it when progressive disclosure is explicitly off on the + * template, even if a non-empty unlock list was persisted from a prior + * canary-on run (resume/canary-off must not permanently shrink the * full-surface template). */ export function filterByUnlockedTiers( toolNames: string[], - unlockedTiers: string[], - templateAllows?: (name: string) => boolean, + unlockedTiers: readonly unknown[], + templateAllows: TierToolCeiling, ): string[] { - // Narrow/bound unlockedTiers: ignore non-string, "core", unknown, duplicates. - const uniqueValidTiers: Exclude[] = [] + // Bound the persisted tier list: ignore non-string, "core", unknown, dupes. + const uniqueValidTiers: UnlockedToolTier[] = [] const seenTier = new Set() for (const raw of unlockedTiers) { if (typeof raw !== 'string') continue @@ -123,11 +156,11 @@ export function filterByUnlockedTiers( if (seenTier.has(raw)) continue if (!Object.hasOwn(BASE2_TIER_TOOL_NAMES, raw)) continue seenTier.add(raw) - uniqueValidTiers.push(raw as Exclude) + uniqueValidTiers.push(raw as UnlockedToolTier) } const allowed = new Set(BASE2_CORE_TOOL_NAMES) for (const tier of uniqueValidTiers) { - for (const name of BASE2_TIER_TOOL_NAMES[tier] ?? []) { + for (const name of BASE2_TIER_TOOL_NAMES[tier]) { allowed.add(name) } } @@ -143,16 +176,17 @@ export function filterByUnlockedTiers( for (const name of toolNames) { if (keep(name)) result.push(name) } - // Add newly unlocked tier tools the core-only template did not already - // list. Caller MUST pass templateAllows when operating on a mode-resolved - // surface (fast/plan-only) — undefined defaults to allow-all only for - // non-mode-gated callers/tests; mode-gated callers that omit the ceiling - // risk widening beyond the template's mode-appropriate surface - // (e.g. exposing ask_user/write_todos in fast/plan-only via CORE ceiling). + // Add newly unlocked tier tools the core-only template did not already list. + // The templateAllows ceiling is what keeps this from re-adding a tier tool + // the current mode forbids (e.g. edit_transaction / run_targeted_validation + // in plan-only mode, or run_terminal_command outside execute-plan); only the + // explicit ALLOW_ALL_TIER_TOOLS sentinel admits every unlocked tier tool. for (const tier of uniqueValidTiers) { - for (const name of BASE2_TIER_TOOL_NAMES[tier] ?? []) { + for (const name of BASE2_TIER_TOOL_NAMES[tier]) { if (seen.has(name)) continue - if (templateAllows !== undefined && !templateAllows(name)) continue + if (templateAllows !== ALLOW_ALL_TIER_TOOLS && !templateAllows(name)) { + continue + } seen.add(name) result.push(name) }