diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ae6866..8e9cac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Bkper CLI Agent has a new look: the startup screen shows the Bkper logo in its account type colors, and new Bkper light and dark themes replace Pi's default colors, following your terminal's light or dark background. Custom themes you selected are kept - The startup screen no longer breaks in narrow terminals - Updated Pi to 0.99.2 + - The agent can now run several tool calls in one JavaScript script (codemode), so batch lookups and large outputs are handled in one step and only the result reaches the conversation. Scripts that write to a Book follow the same rule as single commands: the agent shows the exact script and every change first, and runs it only after you confirm. Codemode is on by default; a `defaultTools` setting you set is kept, and `+codemode` or `-codemode` turns it on or off ## [5.2.0] - 2026-10-01 diff --git a/src/agent/extensions/builtins.ts b/src/agent/extensions/builtins.ts index f50a5dc..74aff49 100644 --- a/src/agent/extensions/builtins.ts +++ b/src/agent/extensions/builtins.ts @@ -1,4 +1,5 @@ import { + createCodemodeExtension, getAgentDir, type Extension, type ExtensionAPI, @@ -120,4 +121,6 @@ export function registerBkperAgentBuiltins( dispatchHandoffCommand, getBkperHandoffShortcutFromFile(getAgentDir()) ); + // Registered inactive; the Bkper default tool selection activates it. + createCodemodeExtension()(pi); } diff --git a/src/agent/interactive/settings.ts b/src/agent/interactive/settings.ts index 086838f..0dcd449 100644 --- a/src/agent/interactive/settings.ts +++ b/src/agent/interactive/settings.ts @@ -21,43 +21,116 @@ export interface ResolvedBkperAgentTools { warning?: string; } +// Always selected so the tool set, and with it the prompt cache, stays stable across turns. +const CODEMODE_TOOL = 'codemode'; + +function isToolModifier(entry: unknown): boolean { + return typeof entry === 'string' && (entry.startsWith('+') || entry.startsWith('-')); +} + +/** Applies `+name`/`-name` entries in order, as Pi does for `defaultTools`. */ +function applyToolModifiers(tools: string[], modifiers: string[]): string[] { + const result = [...tools]; + for (const modifier of modifiers) { + const name = modifier.slice(1); + const index = result.indexOf(name); + if (modifier.startsWith('+') && index === -1 && name) { + result.push(name); + } else if (modifier.startsWith('-') && index !== -1) { + result.splice(index, 1); + } + } + return result; +} + +/** + * Pi resolves a `defaultTools` selection without plain tool names in any settings layer against + * its own defaults, which lack codemode and PowerShell. Returns those modifiers, global layer + * first, so they can be applied to the Bkper defaults instead. + */ +function getModifierOnlyToolSelection(layers: unknown[]): string[] | undefined { + const modifiers: string[] = []; + for (const layer of layers) { + if (layer === undefined) { + continue; + } + if (!isModifierList(layer)) { + return undefined; + } + modifiers.push(...layer); + } + return modifiers.length > 0 ? modifiers : undefined; +} + +function isModifierList(layer: unknown): layer is string[] { + return Array.isArray(layer) && layer.every(isToolModifier); +} + +function disableUnavailableShells( + configuredTools: string[], + availability: ShellAvailability +): ResolvedBkperAgentTools { + const unavailableShells = configuredTools.filter( + tool => + (tool === 'bash' && !availability.bash) || + (tool === 'powershell' && !availability.powershell) + ); + const tools = configuredTools.filter(tool => !unavailableShells.includes(tool)); + return unavailableShells.length > 0 + ? { + tools, + warning: `Unavailable configured shell tools were disabled: ${unavailableShells.join( + ', ' + )}.`, + } + : {tools}; +} + export function resolveBkperAgentTools( configuredTools: string[] | undefined, platform: NodeJS.Platform, - availability: ShellAvailability + availability: ShellAvailability, + toolModifiers: string[] = [] ): ResolvedBkperAgentTools { if (configuredTools) { - const unavailableShells = configuredTools.filter( - tool => - (tool === 'bash' && !availability.bash) || - (tool === 'powershell' && !availability.powershell) - ); - const tools = configuredTools.filter(tool => !unavailableShells.includes(tool)); - return unavailableShells.length > 0 - ? { - tools, - warning: `Unavailable configured shell tools were disabled: ${unavailableShells.join( - ', ' - )}.`, - } - : {tools}; + return disableUnavailableShells(configuredTools, availability); + } + + const defaults = selectDefaultBkperAgentTools(platform, availability); + if (toolModifiers.length === 0) { + return defaults; } + const modified = disableUnavailableShells( + applyToolModifiers(defaults.tools, toolModifiers), + availability + ); + const warnings = [defaults.warning, modified.warning].filter( + (warning): warning is string => warning !== undefined + ); + return warnings.length > 0 + ? {tools: modified.tools, warning: warnings.join(' ')} + : {tools: modified.tools}; +} +function selectDefaultBkperAgentTools( + platform: NodeJS.Platform, + availability: ShellAvailability +): ResolvedBkperAgentTools { if (platform === 'win32' && availability.powershell) { - return {tools: ['read', 'powershell', 'edit', 'write']}; + return {tools: ['read', 'powershell', 'edit', 'write', CODEMODE_TOOL]}; } if (availability.bash) { return platform === 'win32' ? { - tools: ['read', 'bash', 'edit', 'write'], + tools: ['read', 'bash', 'edit', 'write', CODEMODE_TOOL], warning: 'PowerShell is unavailable; using Bash instead.', } - : {tools: ['read', 'bash', 'edit', 'write']}; + : {tools: ['read', 'bash', 'edit', 'write', CODEMODE_TOOL]}; } return { - tools: ['read', 'edit', 'write'], + tools: ['read', 'edit', 'write', CODEMODE_TOOL], warning: 'No supported shell is available; command execution is disabled.', }; } @@ -68,6 +141,8 @@ type DefaultToolsSettingsManager = { }; type BkperAgentToolSettingsManager = DefaultToolsSettingsManager & { + getGlobalSettings(): {defaultTools?: string[]}; + getProjectSettings(): {defaultTools?: string[]}; getShellPath(): string | undefined; }; @@ -109,11 +184,17 @@ export function applyBkperAgentToolSelection( effectivePlatform === 'win32' && (forceWindows || canResolveShell(() => getPowerShellConfig())), }; - const resolved = resolveBkperAgentTools( - settingsManager.getDefaultTools(), - effectivePlatform, - availability - ); + const toolModifiers = getModifierOnlyToolSelection([ + settingsManager.getGlobalSettings().defaultTools, + settingsManager.getProjectSettings().defaultTools, + ]); + const resolved = toolModifiers + ? resolveBkperAgentTools(undefined, effectivePlatform, availability, toolModifiers) + : resolveBkperAgentTools( + settingsManager.getDefaultTools(), + effectivePlatform, + availability + ); overrideDefaultTools(settingsManager, resolved.tools); diff --git a/src/agent/system-prompt.ts b/src/agent/system-prompt.ts index 01cf1d3..7a142fe 100644 --- a/src/agent/system-prompt.ts +++ b/src/agent/system-prompt.ts @@ -81,14 +81,25 @@ function getCodingToolDefinitions(selectedTools: string[]) { ].filter(definition => selectedTools.includes(definition.name)); } +// Pi drops tool prompt snippets and guidelines when the system prompt is replaced, and does not +// export codemode's, so Bkper describes codemode itself. +const CODEMODE_PROMPT_SNIPPET = + 'Run JavaScript that calls other tools (chains, loops, Promise.all, filtering large results)'; +const CODEMODE_PROMPT_GUIDELINES = [ + 'Use codemode, not shell loops or jq pipelines, to repeat commands across items, chain their output, or filter large results.', + 'Book writes in a codemode script need the same confirmation as single commands: resolve targets read-only, show the script and changes, run only after the user confirms, and report each item\'s result.', +]; + function buildToolPromptSection(selectedTools: string[]): string { const toolDefinitions = getCodingToolDefinitions(selectedTools); - const toolLines = toolDefinitions - .flatMap(definition => { + const hasCodemode = selectedTools.includes('codemode'); + const toolLines = [ + ...toolDefinitions.flatMap(definition => { const snippet = normalizePromptSnippet(definition.promptSnippet); return snippet ? [`- ${definition.name}: ${snippet}`] : []; - }) - .join('\n'); + }), + ...(hasCodemode ? [`- codemode: ${CODEMODE_PROMPT_SNIPPET}`] : []), + ].join('\n'); const guidelineLines: string[] = []; const seenGuidelines = new Set(); @@ -115,6 +126,9 @@ function buildToolPromptSection(selectedTools: string[]): string { addGuideline(guideline); } } + if (hasCodemode) { + CODEMODE_PROMPT_GUIDELINES.forEach(addGuideline); + } addGuideline('Do not claim builds, tests, or command results unless you actually ran them.'); const toolsList = toolLines.length > 0 ? toolLines : '(none)'; diff --git a/test/unit/agent/extensions/builtins.test.ts b/test/unit/agent/extensions/builtins.test.ts index f2042d5..a794ec6 100644 --- a/test/unit/agent/extensions/builtins.test.ts +++ b/test/unit/agent/extensions/builtins.test.ts @@ -47,6 +47,7 @@ describe('Bkper agent built-in extensions', function () { registerShortcut: sinon.stub(), registerMessageRenderer: sinon.stub(), registerProvider: sinon.stub(), + registerTool: sinon.stub(), } as unknown as ExtensionAPI); expect(registeredEvents).to.deep.equal([ @@ -56,6 +57,30 @@ describe('Bkper agent built-in extensions', function () { ]); }); + it('registers the codemode tool inactive so tool selection decides activation', function () { + const registerTool = sinon.stub(); + + registerBkperAgentBuiltins( + { + on: sinon.stub() as unknown as ExtensionAPI['on'], + registerCommand: sinon.stub(), + registerShortcut: sinon.stub(), + registerMessageRenderer: sinon.stub(), + registerProvider: sinon.stub(), + registerTool, + } as unknown as ExtensionAPI, + sinon.stub().resolves(), + undefined, + {} + ); + + const codemodeTools = registerTool.args + .map(([tool]) => tool as {name: string; defaultActive?: boolean}) + .filter(tool => tool.name === 'codemode'); + expect(codemodeTools).to.have.length(1); + expect(codemodeTools[0]?.defaultActive).to.equal(false); + }); + it('registers Bkper AI through Pi standard OpenAI Responses transport', function () { const providers: Array<{name: string; config: ProviderConfig}> = []; @@ -68,6 +93,7 @@ describe('Bkper agent built-in extensions', function () { registerProvider: (name: string, config: ProviderConfig) => { providers.push({name, config}); }, + registerTool: sinon.stub(), } as unknown as ExtensionAPI, sinon.stub().resolves(), undefined, @@ -100,6 +126,7 @@ describe('Bkper agent built-in extensions', function () { registerShortcut: sinon.stub(), registerMessageRenderer: sinon.stub(), registerProvider, + registerTool: sinon.stub(), } as unknown as ExtensionAPI, sinon.stub().resolves(), undefined, diff --git a/test/unit/agent/interactive/run-agent-mode.test.ts b/test/unit/agent/interactive/run-agent-mode.test.ts index 6ac77ba..6feb867 100644 --- a/test/unit/agent/interactive/run-agent-mode.test.ts +++ b/test/unit/agent/interactive/run-agent-mode.test.ts @@ -50,10 +50,12 @@ describe('runAgentMode', function () { 'powershell', 'edit', 'write', + 'codemode', ]); expect(runtime.session.systemPrompt).to.include( '- powershell: Execute PowerShell commands' ); + expect(runtime.session.systemPrompt).to.include('- codemode:'); expect(runtime.session.systemPrompt).to.not.include('- bash:'); } finally { await runtime.dispose(); diff --git a/test/unit/agent/interactive/settings.test.ts b/test/unit/agent/interactive/settings.test.ts index d16d6fc..51efc31 100644 --- a/test/unit/agent/interactive/settings.test.ts +++ b/test/unit/agent/interactive/settings.test.ts @@ -19,7 +19,7 @@ describe('interactive agent settings', function () { powershell: true, }) ).to.deep.equal({ - tools: ['read', 'powershell', 'edit', 'write'], + tools: ['read', 'powershell', 'edit', 'write', 'codemode'], }); expect( resolveBkperAgentTools(undefined, 'linux', { @@ -27,7 +27,7 @@ describe('interactive agent settings', function () { powershell: false, }) ).to.deep.equal({ - tools: ['read', 'bash', 'edit', 'write'], + tools: ['read', 'bash', 'edit', 'write', 'codemode'], }); }); @@ -39,13 +39,15 @@ describe('interactive agent settings', function () { try { const diagnostics = applyBkperAgentToolSelection({ getDefaultTools: () => undefined, + getGlobalSettings: () => ({}), + getProjectSettings: () => ({}), getShellPath: () => undefined, applyOverrides, }); expect( applyOverrides.calledOnceWithExactly({ - defaultTools: ['read', 'powershell', 'edit', 'write'], + defaultTools: ['read', 'powershell', 'edit', 'write', 'codemode'], }) ).to.be.true; expect(diagnostics).to.deep.equal([]); @@ -65,7 +67,7 @@ describe('interactive agent settings', function () { powershell: false, }) ).to.deep.equal({ - tools: ['read', 'bash', 'edit', 'write'], + tools: ['read', 'bash', 'edit', 'write', 'codemode'], warning: 'PowerShell is unavailable; using Bash instead.', }); }); @@ -77,7 +79,7 @@ describe('interactive agent settings', function () { powershell: false, }) ).to.deep.equal({ - tools: ['read', 'edit', 'write'], + tools: ['read', 'edit', 'write', 'codemode'], warning: 'No supported shell is available; command execution is disabled.', }); }); @@ -95,6 +97,124 @@ describe('interactive agent settings', function () { }); }); + it('keeps an explicit tool selection without adding codemode', function () { + expect( + resolveBkperAgentTools(['read', 'bash'], 'linux', { + bash: true, + powershell: false, + }) + ).to.deep.equal({tools: ['read', 'bash']}); + }); + + it('enables codemode from a +codemode settings entry', function () { + const settingsManager = SettingsManager.inMemory({ + defaultTools: ['read', 'bash', '+codemode'], + }); + + applyBkperAgentToolSelection(settingsManager, 'linux'); + + expect(settingsManager.getDefaultTools()).to.deep.equal(['read', 'bash', 'codemode']); + }); + + it('applies modifier-only settings to the Bkper defaults', function () { + const settingsManager = SettingsManager.inMemory({defaultTools: ['+grep']}); + + applyBkperAgentToolSelection(settingsManager, 'linux'); + + expect(settingsManager.getDefaultTools()).to.deep.equal([ + 'read', + 'bash', + 'edit', + 'write', + 'codemode', + 'grep', + ]); + }); + + it('removes only codemode for a -codemode settings entry', function () { + const settingsManager = SettingsManager.inMemory({defaultTools: ['-codemode']}); + + applyBkperAgentToolSelection(settingsManager, 'linux'); + + expect(settingsManager.getDefaultTools()).to.deep.equal(['read', 'bash', 'edit', 'write']); + }); + + it('keeps PowerShell on Windows for modifier-only settings', function () { + const previousOverride = process.env.BKPER_AGENT_FORCE_PLATFORM; + process.env.BKPER_AGENT_FORCE_PLATFORM = 'win32'; + + try { + const settingsManager = SettingsManager.inMemory({defaultTools: ['+grep']}); + + applyBkperAgentToolSelection(settingsManager); + + expect(settingsManager.getDefaultTools()).to.deep.equal([ + 'read', + 'powershell', + 'edit', + 'write', + 'codemode', + 'grep', + ]); + } finally { + if (previousOverride === undefined) { + delete process.env.BKPER_AGENT_FORCE_PLATFORM; + } else { + process.env.BKPER_AGENT_FORCE_PLATFORM = previousOverride; + } + } + }); + + it('applies global then project modifiers in order', function () { + const applyOverrides = sinon.stub(); + + applyBkperAgentToolSelection( + { + getDefaultTools: () => ['read', 'bash', 'edit', 'write', 'grep'], + getGlobalSettings: () => ({defaultTools: ['+grep']}), + getProjectSettings: () => ({defaultTools: ['-codemode']}), + getShellPath: () => undefined, + applyOverrides, + }, + 'linux' + ); + + expect( + applyOverrides.calledOnceWithExactly({ + defaultTools: ['read', 'bash', 'edit', 'write', 'grep'], + }) + ).to.be.true; + }); + + it('uses the resolved selection when any settings layer lists plain tool names', function () { + const applyOverrides = sinon.stub(); + + applyBkperAgentToolSelection( + { + getDefaultTools: () => ['read', 'grep'], + getGlobalSettings: () => ({defaultTools: ['+codemode']}), + getProjectSettings: () => ({defaultTools: ['read', 'grep']}), + getShellPath: () => undefined, + applyOverrides, + }, + 'linux' + ); + + expect(applyOverrides.calledOnceWithExactly({defaultTools: ['read', 'grep']})).to.be + .true; + }); + + it('disables an unavailable shell added by a modifier', function () { + expect( + resolveBkperAgentTools(undefined, 'linux', {bash: true, powershell: false}, [ + '+powershell', + ]) + ).to.deep.equal({ + tools: ['read', 'bash', 'edit', 'write', 'codemode'], + warning: 'Unavailable configured shell tools were disabled: powershell.', + }); + }); + it('disables configured shells even when no configured tool remains', function () { const settingsManager = SettingsManager.inMemory({ defaultTools: ['bash'], diff --git a/test/unit/agent/system-prompt.test.ts b/test/unit/agent/system-prompt.test.ts index b836add..95dec12 100644 --- a/test/unit/agent/system-prompt.test.ts +++ b/test/unit/agent/system-prompt.test.ts @@ -23,4 +23,16 @@ describe('agent system prompt', function () { expect(full).to.include('- powershell: Execute PowerShell commands'); expect(full).to.not.include('- bash:'); }); + + it('lists codemode as an available tool when codemode is selected', function () { + const full = getBkperAgentSystemPrompt(['read', 'bash', 'edit', 'write', 'codemode']); + + expect(full).to.include('- codemode:'); + }); + + it('omits codemode guidance when codemode is not selected', function () { + const full = getBkperAgentSystemPrompt(['read', 'bash', 'edit', 'write']); + + expect(full).to.not.include('codemode'); + }); });