From d5e8c6a6b5752db8aae7fa4a3f703a4a0beabc0f Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 23 Mar 2026 21:25:44 +0000 Subject: [PATCH 1/2] feat(backends): add NativeToolEngine abstract base class for subprocess engines --- src/backends/index.ts | 1 + src/backends/shared/NativeToolEngine.ts | 110 ++++++++ src/backends/shared/index.ts | 1 + tests/unit/backends/NativeToolEngine.test.ts | 272 +++++++++++++++++++ 4 files changed, 384 insertions(+) create mode 100644 src/backends/shared/NativeToolEngine.ts create mode 100644 src/backends/shared/index.ts create mode 100644 tests/unit/backends/NativeToolEngine.test.ts diff --git a/src/backends/index.ts b/src/backends/index.ts index a26bc367..0349ebac 100644 --- a/src/backends/index.ts +++ b/src/backends/index.ts @@ -10,6 +10,7 @@ export type { ProgressReporter, ToolManifest, } from './types.js'; +export { NativeToolEngine } from './shared/index.js'; export { getEngine, diff --git a/src/backends/shared/NativeToolEngine.ts b/src/backends/shared/NativeToolEngine.ts new file mode 100644 index 00000000..9e394110 --- /dev/null +++ b/src/backends/shared/NativeToolEngine.ts @@ -0,0 +1,110 @@ +/** + * NativeToolEngine — abstract base class for subprocess-based agent engines. + * + * Extracts shared patterns common to Claude Code, Codex, and OpenCode engines: + * - System/task prompt building via buildSystemPrompt / buildTaskPrompt + * - Environment building via buildEngineEnv with engine-specific allowlists + * - Context file cleanup in afterExecute + * - supportsAgentType returning true (all native-tool engines support every agent type) + * + * Each concrete engine subclass must implement: + * - definition — the engine's AgentEngineDefinition + * - getAllowedEnvExact() — engine-specific env var allowlist + * - getExtraEnvVars() — unconditionally injected env vars (e.g. CI=true) + * - resolveEngineModel() — cascade model string → engine-specific model identifier + * - executeTurn() — the actual subprocess invocation for a single turn + * + * LLMist stays separate — it is an in-process SDK, fundamentally different from + * the subprocess pattern captured here. + */ + +import type { + AgentEngine, + AgentEngineDefinition, + AgentEngineResult, + AgentExecutionPlan, +} from '../types.js'; +import { cleanupContextFiles } from './contextFiles.js'; +import { buildEngineEnv } from './envBuilder.js'; + +export abstract class NativeToolEngine implements AgentEngine { + // ------------------------------------------------------------------------- + // Abstract members — subclasses must implement these + // ------------------------------------------------------------------------- + + abstract readonly definition: AgentEngineDefinition; + + /** + * Engine-specific exact-match env var allowlist. + * Merged on top of the shared set by buildEnv(). + */ + abstract getAllowedEnvExact(): Set; + + /** + * Extra env vars injected unconditionally into every subprocess + * (e.g. { CI: 'true', CODEX_DISABLE_UPDATE_NOTIFIER: '1' }). + */ + abstract getExtraEnvVars(): Record; + + /** + * Resolve a CASCADE model string to the engine-specific model identifier. + * Throw an Error if the model is incompatible with this engine. + */ + abstract resolveEngineModel(cascadeModel: string): string; + + // ------------------------------------------------------------------------- + // Shared / template methods + // ------------------------------------------------------------------------- + + /** + * Delegates to resolveEngineModel so the AgentEngine.resolveModel() contract is + * satisfied without requiring subclasses to remember to call super. + */ + resolveModel(cascadeModel: string): string { + return this.resolveEngineModel(cascadeModel); + } + + /** + * All native-tool engines support every agent type. + * Override in a subclass only if you need to restrict this. + */ + supportsAgentType(_agentType: string): boolean { + return true; + } + + /** + * Build a sanitised environment record for the subprocess. + * + * Calls buildEngineEnv with: + * - allowedEnvExact from getAllowedEnvExact() + * - extraVars from getExtraEnvVars() + * - projectSecrets and path dirs from the execution plan + */ + buildEnv( + projectSecrets?: Record, + cliToolsDir?: string, + nativeToolShimDir?: string, + ): Record { + return buildEngineEnv({ + allowedEnvExact: this.getAllowedEnvExact(), + extraVars: this.getExtraEnvVars(), + projectSecrets, + cliToolsDir, + nativeToolShimDir, + }); + } + + /** + * Clean up offloaded context files after execution. + * Engines that need additional cleanup should override this method and + * call super.afterExecute() to ensure context files are removed. + */ + async afterExecute(plan: AgentExecutionPlan, _result: AgentEngineResult): Promise { + await cleanupContextFiles(plan.repoDir); + } + + /** + * Subclasses must provide the actual subprocess execution logic. + */ + abstract execute(input: AgentExecutionPlan): Promise; +} diff --git a/src/backends/shared/index.ts b/src/backends/shared/index.ts new file mode 100644 index 00000000..a35261ee --- /dev/null +++ b/src/backends/shared/index.ts @@ -0,0 +1 @@ +export { NativeToolEngine } from './NativeToolEngine.js'; diff --git a/tests/unit/backends/NativeToolEngine.test.ts b/tests/unit/backends/NativeToolEngine.test.ts new file mode 100644 index 00000000..522ce35f --- /dev/null +++ b/tests/unit/backends/NativeToolEngine.test.ts @@ -0,0 +1,272 @@ +/** + * Tests for the NativeToolEngine abstract base class. + * + * We exercise the base class through a StubEngine that implements only the + * required abstract members without any real subprocess logic. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NativeToolEngine } from '../../../src/backends/shared/NativeToolEngine.js'; +import type { + AgentEngineDefinition, + AgentEngineResult, + AgentExecutionPlan, +} from '../../../src/backends/types.js'; + +// --------------------------------------------------------------------------- +// Mock cleanupContextFiles to avoid filesystem I/O in tests +// --------------------------------------------------------------------------- +vi.mock('../../../src/backends/shared/contextFiles.js', () => ({ + cleanupContextFiles: vi.fn().mockResolvedValue(undefined), +})); + +import { cleanupContextFiles } from '../../../src/backends/shared/contextFiles.js'; + +// --------------------------------------------------------------------------- +// Mock buildEngineEnv so we can verify it is called correctly +// --------------------------------------------------------------------------- +vi.mock('../../../src/backends/shared/envBuilder.js', () => ({ + buildEngineEnv: vi.fn().mockReturnValue({ HOME: '/test', PATH: '/usr/bin' }), +})); + +import { buildEngineEnv } from '../../../src/backends/shared/envBuilder.js'; + +// --------------------------------------------------------------------------- +// StubEngine — minimal concrete subclass for testing the base class +// --------------------------------------------------------------------------- + +const STUB_ENGINE_DEFINITION: AgentEngineDefinition = { + id: 'stub', + label: 'Stub Engine', + description: 'Test stub — not a real engine.', + archetype: 'native-tool', + capabilities: ['native_file_edit_tools'], + modelSelection: { type: 'free-text' }, + logLabel: 'Stub Log', +}; + +class StubEngine extends NativeToolEngine { + readonly definition = STUB_ENGINE_DEFINITION; + + getAllowedEnvExact(): Set { + return new Set(['STUB_API_KEY']); + } + + getExtraEnvVars(): Record { + return { CI: 'true', STUB_FLAG: '1' }; + } + + resolveEngineModel(cascadeModel: string): string { + if (cascadeModel === 'stub:v1') return 'stub-v1'; + throw new Error(`Incompatible model: ${cascadeModel}`); + } + + // Simple stub: always returns a successful result + async execute(_input: AgentExecutionPlan): Promise { + return { success: true, output: 'stub output' }; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMinimalPlan(overrides?: Partial): AgentExecutionPlan { + return { + agentType: 'implementation', + project: { + id: 'test-project', + orgId: 'org-1', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'trello' }, + } as AgentExecutionPlan['project'], + config: {} as AgentExecutionPlan['config'], + repoDir: '/tmp/test-repo', + agentInput: {} as AgentExecutionPlan['agentInput'], + progressReporter: { + onIteration: vi.fn().mockResolvedValue(undefined), + onText: vi.fn(), + onToolCall: vi.fn(), + }, + logWriter: vi.fn(), + systemPrompt: 'You are a helpful assistant.', + taskPrompt: 'Do the task.', + availableTools: [], + contextInjections: [], + maxIterations: 10, + model: 'stub:v1', + cliToolsDir: '/usr/local/cascade-tools', + ...overrides, + } as AgentExecutionPlan; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('NativeToolEngine (via StubEngine)', () => { + let engine: StubEngine; + + beforeEach(() => { + engine = new StubEngine(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ------------------------------------------------------------------------- + // definition + // ------------------------------------------------------------------------- + describe('definition', () => { + it('exposes the definition from the subclass', () => { + expect(engine.definition).toBe(STUB_ENGINE_DEFINITION); + }); + + it('has archetype = native-tool', () => { + expect(engine.definition.archetype).toBe('native-tool'); + }); + }); + + // ------------------------------------------------------------------------- + // supportsAgentType + // ------------------------------------------------------------------------- + describe('supportsAgentType', () => { + it('returns true for any agent type', () => { + expect(engine.supportsAgentType('implementation')).toBe(true); + expect(engine.supportsAgentType('review')).toBe(true); + expect(engine.supportsAgentType('splitting')).toBe(true); + expect(engine.supportsAgentType('some-unknown-agent')).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // resolveModel — delegates to resolveEngineModel + // ------------------------------------------------------------------------- + describe('resolveModel', () => { + it('returns the engine-specific model string for a compatible model', () => { + expect(engine.resolveModel('stub:v1')).toBe('stub-v1'); + }); + + it('throws for an incompatible model', () => { + expect(() => engine.resolveModel('claude-3-5-sonnet')).toThrow('Incompatible model'); + }); + }); + + // ------------------------------------------------------------------------- + // buildEnv — calls buildEngineEnv with the right arguments + // ------------------------------------------------------------------------- + describe('buildEnv', () => { + it('calls buildEngineEnv with allowedEnvExact from getAllowedEnvExact()', () => { + engine.buildEnv(); + + expect(buildEngineEnv).toHaveBeenCalledWith( + expect.objectContaining({ + allowedEnvExact: new Set(['STUB_API_KEY']), + }), + ); + }); + + it('calls buildEngineEnv with extraVars from getExtraEnvVars()', () => { + engine.buildEnv(); + + expect(buildEngineEnv).toHaveBeenCalledWith( + expect.objectContaining({ + extraVars: { CI: 'true', STUB_FLAG: '1' }, + }), + ); + }); + + it('passes projectSecrets through to buildEngineEnv', () => { + const secrets = { STUB_API_KEY: 'key-value' }; + engine.buildEnv(secrets); + + expect(buildEngineEnv).toHaveBeenCalledWith( + expect.objectContaining({ projectSecrets: secrets }), + ); + }); + + it('passes cliToolsDir through to buildEngineEnv', () => { + engine.buildEnv(undefined, '/usr/local/cascade-tools'); + + expect(buildEngineEnv).toHaveBeenCalledWith( + expect.objectContaining({ cliToolsDir: '/usr/local/cascade-tools' }), + ); + }); + + it('passes nativeToolShimDir through to buildEngineEnv', () => { + engine.buildEnv(undefined, undefined, '/tmp/shims'); + + expect(buildEngineEnv).toHaveBeenCalledWith( + expect.objectContaining({ nativeToolShimDir: '/tmp/shims' }), + ); + }); + + it('returns the env record produced by buildEngineEnv', () => { + vi.mocked(buildEngineEnv).mockReturnValueOnce({ HOME: '/test', PATH: '/usr/bin' }); + const env = engine.buildEnv(); + expect(env).toEqual({ HOME: '/test', PATH: '/usr/bin' }); + }); + }); + + // ------------------------------------------------------------------------- + // afterExecute — calls cleanupContextFiles + // ------------------------------------------------------------------------- + describe('afterExecute', () => { + it('calls cleanupContextFiles with the plan repoDir', async () => { + const plan = makeMinimalPlan({ repoDir: '/tmp/test-repo' }); + const result: AgentEngineResult = { success: true, output: '' }; + + await engine.afterExecute(plan, result); + + expect(cleanupContextFiles).toHaveBeenCalledWith('/tmp/test-repo'); + }); + + it('resolves without throwing when cleanupContextFiles succeeds', async () => { + const plan = makeMinimalPlan(); + const result: AgentEngineResult = { success: true, output: '' }; + + await expect(engine.afterExecute(plan, result)).resolves.toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // execute — delegated to subclass + // ------------------------------------------------------------------------- + describe('execute', () => { + it('returns the result from the subclass execute implementation', async () => { + const plan = makeMinimalPlan(); + const result = await engine.execute(plan); + expect(result).toEqual({ success: true, output: 'stub output' }); + }); + }); + + // ------------------------------------------------------------------------- + // implements AgentEngine interface + // ------------------------------------------------------------------------- + describe('AgentEngine interface conformance', () => { + it('has a definition property', () => { + expect(engine.definition).toBeDefined(); + }); + + it('has an execute function', () => { + expect(typeof engine.execute).toBe('function'); + }); + + it('has a supportsAgentType function', () => { + expect(typeof engine.supportsAgentType).toBe('function'); + }); + + it('has a resolveModel function', () => { + expect(typeof engine.resolveModel).toBe('function'); + }); + + it('has an afterExecute function', () => { + expect(typeof engine.afterExecute).toBe('function'); + }); + }); +}); From 96b1f13ae0ffb7354d53896b99b121f9affb59bb Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 23 Mar 2026 21:34:25 +0000 Subject: [PATCH 2/2] docs(backends): fix NativeToolEngine class-level JSDoc inaccuracies Remove the erroneous bullet about buildSystemPrompt/buildTaskPrompt (those functions are standalone imports used by each engine's own execute(), not a base class responsibility). Also correct the abstract method name from executeTurn() to execute() to match the actual contract. Co-Authored-By: Claude Opus 4.6 --- src/backends/shared/NativeToolEngine.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/backends/shared/NativeToolEngine.ts b/src/backends/shared/NativeToolEngine.ts index 9e394110..58b1ca9b 100644 --- a/src/backends/shared/NativeToolEngine.ts +++ b/src/backends/shared/NativeToolEngine.ts @@ -2,7 +2,6 @@ * NativeToolEngine — abstract base class for subprocess-based agent engines. * * Extracts shared patterns common to Claude Code, Codex, and OpenCode engines: - * - System/task prompt building via buildSystemPrompt / buildTaskPrompt * - Environment building via buildEngineEnv with engine-specific allowlists * - Context file cleanup in afterExecute * - supportsAgentType returning true (all native-tool engines support every agent type) @@ -12,7 +11,7 @@ * - getAllowedEnvExact() — engine-specific env var allowlist * - getExtraEnvVars() — unconditionally injected env vars (e.g. CI=true) * - resolveEngineModel() — cascade model string → engine-specific model identifier - * - executeTurn() — the actual subprocess invocation for a single turn + * - execute() — the actual subprocess execution logic * * LLMist stays separate — it is an in-process SDK, fundamentally different from * the subprocess pattern captured here.