From 1cddfe8e61ea522ba1b6dfa567a091c5c2317af0 Mon Sep 17 00:00:00 2001 From: maelcaldas Date: Thu, 1 Oct 2026 15:45:13 -0300 Subject: [PATCH 1/3] feat(agent): enable codemode in interactive sessions Register pi's codemode tool with the Bkper built-ins and select it in the Bkper default tool lists, so the tool set stays stable across turns. Explicit defaultTools settings are kept. Describe codemode in the Bkper system prompt, which replaces pi's and so drops the tool's own snippet and guidelines. Book writes from scripts follow the single-command confirmation rule: resolve targets read-only, show the exact script and changes, run after confirmation, and return per-item results. --- CHANGELOG.md | 1 + src/agent/extensions/builtins.ts | 3 ++ src/agent/interactive/settings.ts | 11 ++++--- src/agent/system-prompt.ts | 22 +++++++++++--- test/unit/agent/extensions/builtins.test.ts | 27 +++++++++++++++++ .../agent/interactive/run-agent-mode.test.ts | 2 ++ test/unit/agent/interactive/settings.test.ts | 29 +++++++++++++++---- test/unit/agent/system-prompt.test.ts | 15 ++++++++++ 8 files changed, 97 insertions(+), 13 deletions(-) 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..e973616 100644 --- a/src/agent/interactive/settings.ts +++ b/src/agent/interactive/settings.ts @@ -21,6 +21,9 @@ 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'; + export function resolveBkperAgentTools( configuredTools: string[] | undefined, platform: NodeJS.Platform, @@ -44,20 +47,20 @@ export function resolveBkperAgentTools( } 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.', }; } diff --git a/src/agent/system-prompt.ts b/src/agent/system-prompt.ts index 01cf1d3..efbfd7d 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 to batch or chain tool calls, or to filter large tool output down to what you need, instead of issuing many individual tool calls.', + 'A codemode script that writes to a Book follows the same confirmation rule as a single command. First resolve the targets read-only, then show the exact script and every change it will make, and run it only after the user confirms. Return a per-item result so partial failures are visible.', +]; + 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..2e8b674 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'], }); }); @@ -45,7 +45,7 @@ describe('interactive agent settings', function () { expect( applyOverrides.calledOnceWithExactly({ - defaultTools: ['read', 'powershell', 'edit', 'write'], + defaultTools: ['read', 'powershell', 'edit', 'write', 'codemode'], }) ).to.be.true; expect(diagnostics).to.deep.equal([]); @@ -65,7 +65,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 +77,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 +95,25 @@ 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('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..f1bf078 100644 --- a/test/unit/agent/system-prompt.test.ts +++ b/test/unit/agent/system-prompt.test.ts @@ -23,4 +23,19 @@ describe('agent system prompt', function () { expect(full).to.include('- powershell: Execute PowerShell commands'); expect(full).to.not.include('- bash:'); }); + + it('describes codemode and requires confirmation for Book writes in scripts when codemode is selected', function () { + const full = getBkperAgentSystemPrompt(['read', 'bash', 'edit', 'write', 'codemode']); + + expect(full).to.include('- codemode:'); + expect(full).to.match( + /codemode script that writes to a Book follows the same confirmation rule/ + ); + }); + + it('omits codemode guidance when codemode is not selected', function () { + const full = getBkperAgentSystemPrompt(['read', 'bash', 'edit', 'write']); + + expect(full).to.not.include('codemode'); + }); }); From 1c2037e49c080629ba23afdb21b4cdeb8ac55d9c Mon Sep 17 00:00:00 2001 From: maelcaldas Date: Thu, 1 Oct 2026 15:49:03 -0300 Subject: [PATCH 2/3] fix(agent): apply modifier-only defaultTools to the Bkper defaults Pi resolves a defaultTools selection with only +name/-name entries in every settings layer against its own defaults, which lack codemode and PowerShell. A shared ~/.pi/agent setting such as ["+grep"] therefore dropped codemode, and on Windows replaced PowerShell with Bash. Read the raw global and project layers; when no layer lists plain tool names, apply their modifiers to the Bkper defaults instead. Shells added by a modifier still go through the unavailable-shell filter. --- src/agent/interactive/settings.ts | 118 +++++++++++++++---- test/unit/agent/interactive/settings.test.ts | 101 ++++++++++++++++ 2 files changed, 199 insertions(+), 20 deletions(-) diff --git a/src/agent/interactive/settings.ts b/src/agent/interactive/settings.ts index e973616..0dcd449 100644 --- a/src/agent/interactive/settings.ts +++ b/src/agent/interactive/settings.ts @@ -24,28 +24,98 @@ export interface ResolvedBkperAgentTools { // 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', CODEMODE_TOOL]}; } @@ -71,6 +141,8 @@ type DefaultToolsSettingsManager = { }; type BkperAgentToolSettingsManager = DefaultToolsSettingsManager & { + getGlobalSettings(): {defaultTools?: string[]}; + getProjectSettings(): {defaultTools?: string[]}; getShellPath(): string | undefined; }; @@ -112,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/test/unit/agent/interactive/settings.test.ts b/test/unit/agent/interactive/settings.test.ts index 2e8b674..51efc31 100644 --- a/test/unit/agent/interactive/settings.test.ts +++ b/test/unit/agent/interactive/settings.test.ts @@ -39,6 +39,8 @@ describe('interactive agent settings', function () { try { const diagnostics = applyBkperAgentToolSelection({ getDefaultTools: () => undefined, + getGlobalSettings: () => ({}), + getProjectSettings: () => ({}), getShellPath: () => undefined, applyOverrides, }); @@ -114,6 +116,105 @@ describe('interactive agent settings', function () { 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'], From bb6d35d520806b750a4dacd9c8bfe3c446587003 Mon Sep 17 00:00:00 2001 From: maelcaldas Date: Thu, 1 Oct 2026 17:15:39 -0300 Subject: [PATCH 3/3] fix(agent): steer repeated bkper commands into codemode With the generic codemode guideline the model fanned out bkper commands with shell loops and jq instead. Name those alternatives in a shorter guideline, and shorten the Book write confirmation guideline without dropping a step. Drop the test that matched the guideline wording; the tool listing and the codemode-off case stay covered. --- src/agent/system-prompt.ts | 4 ++-- test/unit/agent/system-prompt.test.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/agent/system-prompt.ts b/src/agent/system-prompt.ts index efbfd7d..7a142fe 100644 --- a/src/agent/system-prompt.ts +++ b/src/agent/system-prompt.ts @@ -86,8 +86,8 @@ function getCodingToolDefinitions(selectedTools: string[]) { const CODEMODE_PROMPT_SNIPPET = 'Run JavaScript that calls other tools (chains, loops, Promise.all, filtering large results)'; const CODEMODE_PROMPT_GUIDELINES = [ - 'Use codemode to batch or chain tool calls, or to filter large tool output down to what you need, instead of issuing many individual tool calls.', - 'A codemode script that writes to a Book follows the same confirmation rule as a single command. First resolve the targets read-only, then show the exact script and every change it will make, and run it only after the user confirms. Return a per-item result so partial failures are visible.', + '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 { diff --git a/test/unit/agent/system-prompt.test.ts b/test/unit/agent/system-prompt.test.ts index f1bf078..95dec12 100644 --- a/test/unit/agent/system-prompt.test.ts +++ b/test/unit/agent/system-prompt.test.ts @@ -24,13 +24,10 @@ describe('agent system prompt', function () { expect(full).to.not.include('- bash:'); }); - it('describes codemode and requires confirmation for Book writes in scripts when codemode is selected', function () { + 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:'); - expect(full).to.match( - /codemode script that writes to a Book follows the same confirmation rule/ - ); }); it('omits codemode guidance when codemode is not selected', function () {